clauddy 1.24.0 → 1.26.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 +46 -1
- package/bin/clauddy.js +12 -2
- package/main.js +154 -3
- package/package.json +3 -1
- package/preload.js +5 -0
- package/reminders.js +53 -0
- package/renderer/companion.js +48 -0
- package/renderer/index.html +37 -3
- package/renderer/pet.js +549 -12
- package/renderer/style.css +533 -31
- package/renderer/voice.js +243 -0
package/renderer/pet.js
CHANGED
|
@@ -471,10 +471,280 @@ 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 || document.body.classList.contains('pet-only')) 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
|
-
// time:
|
|
477
|
-
//
|
|
746
|
+
// time: tabs own the chip, meters, breakdowns and Usage arrow. The expanded
|
|
747
|
+
// scene follows that view; compact pets follow activity across both services.
|
|
478
748
|
const PROVIDER_KEY = 'clauddy.provider'
|
|
479
749
|
let codexData = null // last Codex payload; null while Codex isn't enabled
|
|
480
750
|
let pickedProvider = (() => {
|
|
@@ -629,12 +899,204 @@ el('harness-tabs').addEventListener('keydown', (e) => {
|
|
|
629
899
|
el(`tab-${providers[next]}`).focus()
|
|
630
900
|
})
|
|
631
901
|
|
|
902
|
+
// ---- companion: pet-only size, reset reminders, and provider reactions -------
|
|
903
|
+
const SIZE_KEY = 'clauddy.size'
|
|
904
|
+
let sizeLoaded = false
|
|
905
|
+
let peekTimer = null
|
|
906
|
+
let nativePetInside = null
|
|
907
|
+
let noticeTimer = null
|
|
908
|
+
let reminders = { claude: null, codex: null }
|
|
909
|
+
const activityTracker = Companion.createActivityTracker()
|
|
910
|
+
|
|
911
|
+
function setPetPeek(on) {
|
|
912
|
+
if (!document.body.classList.contains('pet-only')) return
|
|
913
|
+
clearTimeout(peekTimer)
|
|
914
|
+
document.body.classList.toggle('pet-peek', on)
|
|
915
|
+
}
|
|
916
|
+
function setDisplaySize(size, remember = true) {
|
|
917
|
+
if (!['expanded', 'compact', 'pet'].includes(size)) size = 'expanded'
|
|
918
|
+
if (currentConfig.mode === 'menubar' && size === 'pet') size = 'compact'
|
|
919
|
+
hideBubble()
|
|
920
|
+
clearTimeout(peekTimer)
|
|
921
|
+
document.body.classList.remove('pet-peek')
|
|
922
|
+
document.body.classList.toggle('collapsed', size !== 'expanded')
|
|
923
|
+
document.body.classList.toggle('pet-only', size === 'pet')
|
|
924
|
+
nativePetInside = null
|
|
925
|
+
window.api.watchPetPointer(size === 'pet')
|
|
926
|
+
el('pet').tabIndex = size === 'pet' ? 0 : -1
|
|
927
|
+
if (size === 'pet') {
|
|
928
|
+
el('pet').setAttribute('role', 'button')
|
|
929
|
+
el('pet').setAttribute('aria-label', 'Drag to move; press Enter to open compact monitor')
|
|
930
|
+
} else {
|
|
931
|
+
el('pet').removeAttribute('role')
|
|
932
|
+
el('pet').removeAttribute('aria-label')
|
|
933
|
+
}
|
|
934
|
+
if (remember) {
|
|
935
|
+
try {
|
|
936
|
+
localStorage.setItem(SIZE_KEY, size)
|
|
937
|
+
} catch {}
|
|
938
|
+
}
|
|
939
|
+
paint()
|
|
940
|
+
fitSize()
|
|
941
|
+
}
|
|
942
|
+
el('pet-only-toggle').addEventListener('click', () => setDisplaySize('pet'))
|
|
943
|
+
el('pet-restore').addEventListener('click', () => setDisplaySize('compact'))
|
|
944
|
+
window.api.onPetPointer((point) => {
|
|
945
|
+
if (!document.body.classList.contains('pet-only')) return
|
|
946
|
+
const contains = (rect) =>
|
|
947
|
+
point &&
|
|
948
|
+
point.x >= rect.left &&
|
|
949
|
+
point.x <= rect.right &&
|
|
950
|
+
point.y >= rect.top &&
|
|
951
|
+
point.y <= rect.bottom
|
|
952
|
+
// Keep the preview open across the small gap to its controls. It opens from
|
|
953
|
+
// the sprite, not from the otherwise empty space below it.
|
|
954
|
+
const inside = !!(
|
|
955
|
+
contains(el('pet').getBoundingClientRect()) ||
|
|
956
|
+
(document.body.classList.contains('pet-peek') && contains(el('card').getBoundingClientRect()))
|
|
957
|
+
)
|
|
958
|
+
if (inside === nativePetInside) return
|
|
959
|
+
nativePetInside = inside
|
|
960
|
+
clearTimeout(peekTimer)
|
|
961
|
+
if (inside) setPetPeek(true)
|
|
962
|
+
else peekTimer = setTimeout(() => setPetPeek(false), 180)
|
|
963
|
+
})
|
|
964
|
+
window.addEventListener('blur', () => setPetPeek(false))
|
|
965
|
+
el('card').addEventListener('focusin', () => setPetPeek(true))
|
|
966
|
+
el('card').addEventListener('focusout', (e) => {
|
|
967
|
+
if (!el('card').contains(e.relatedTarget)) setPetPeek(false)
|
|
968
|
+
})
|
|
969
|
+
el('pet').addEventListener('keydown', (e) => {
|
|
970
|
+
if (!document.body.classList.contains('pet-only')) return
|
|
971
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
972
|
+
e.preventDefault()
|
|
973
|
+
setDisplaySize('compact')
|
|
974
|
+
} else if (e.key === 'Escape') {
|
|
975
|
+
el('pet').blur()
|
|
976
|
+
setPetPeek(false)
|
|
977
|
+
}
|
|
978
|
+
})
|
|
979
|
+
for (const button of document.querySelectorAll('#pet-glance button[data-provider]')) {
|
|
980
|
+
button.addEventListener('click', () => pickProvider(button.dataset.provider))
|
|
981
|
+
}
|
|
982
|
+
function paintCompanion(view) {
|
|
983
|
+
for (const provider of ['claude', 'codex']) {
|
|
984
|
+
const connected = provider === 'claude' ? claudeOn() : codexOn()
|
|
985
|
+
const session = provider === 'claude' ? realUsage?.session : codexData?.session
|
|
986
|
+
const button = el(`glance-${provider}`)
|
|
987
|
+
button.hidden = !connected
|
|
988
|
+
button.setAttribute('aria-pressed', String(provider === view))
|
|
989
|
+
button.classList.toggle('urgent', session?.pct >= warnAt())
|
|
990
|
+
el(`glance-${provider}-value`).textContent = pctText(session?.pct)
|
|
991
|
+
const stale = provider === 'codex' && codexStale(codexData)
|
|
992
|
+
button.title = stale
|
|
993
|
+
? 'Last recorded usage · waiting for a fresh reading'
|
|
994
|
+
: `Show ${provider} usage`
|
|
995
|
+
button.classList.toggle('stale-reading', !!stale)
|
|
996
|
+
}
|
|
997
|
+
el('glance-empty').hidden = claudeOn() || codexOn()
|
|
998
|
+
const session = view === 'claude' ? realUsage?.session : codexData?.session
|
|
999
|
+
const reminder = reminders[view]
|
|
1000
|
+
const stale =
|
|
1001
|
+
view === 'codex'
|
|
1002
|
+
? !codexData?.limitsAt || codexStale(codexData)
|
|
1003
|
+
: !realUsageReadAt || Date.now() - realUsageReadAt > STALE_MS
|
|
1004
|
+
const available = session?.pct != null && session.resetMs > 0 && !stale
|
|
1005
|
+
const name = view === 'codex' ? 'Codex' : 'Claude'
|
|
1006
|
+
for (const id of ['reminder-toggle', 'mini-reminder']) {
|
|
1007
|
+
const button = el(id)
|
|
1008
|
+
button.hidden = !reminder && (!available || session.pct < warnAt())
|
|
1009
|
+
button.setAttribute('aria-pressed', String(!!reminder))
|
|
1010
|
+
button.textContent = reminder ? '✓ Reminder on' : 'Notify at reset'
|
|
1011
|
+
button.title = reminder
|
|
1012
|
+
? `Cancel ${name} reminder for ${new Date(reminder.at).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`
|
|
1013
|
+
: `Remind me when ${name}'s session reset is due`
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
function companionNotice(text, provider = null) {
|
|
1017
|
+
clearTimeout(noticeTimer)
|
|
1018
|
+
const notice = el('companion-notice')
|
|
1019
|
+
notice.textContent = text
|
|
1020
|
+
notice.title = text
|
|
1021
|
+
notice.dataset.provider = provider || ''
|
|
1022
|
+
notice.hidden = false
|
|
1023
|
+
fitSize()
|
|
1024
|
+
noticeTimer = setTimeout(() => {
|
|
1025
|
+
notice.hidden = true
|
|
1026
|
+
fitSize()
|
|
1027
|
+
}, 5000)
|
|
1028
|
+
}
|
|
1029
|
+
for (const id of ['reminder-toggle', 'mini-reminder']) {
|
|
1030
|
+
el(id).addEventListener('click', () => {
|
|
1031
|
+
const provider = viewProvider()
|
|
1032
|
+
window.api.setReminder(provider, !reminders[provider])
|
|
1033
|
+
})
|
|
1034
|
+
}
|
|
1035
|
+
window.api.onReminders((state) => {
|
|
1036
|
+
reminders = state || { claude: null, codex: null }
|
|
1037
|
+
if (state?.error) companionNotice(state.error)
|
|
1038
|
+
paintCompanion(viewProvider())
|
|
1039
|
+
fitSize()
|
|
1040
|
+
})
|
|
1041
|
+
window.api.onReminderDue((event) => {
|
|
1042
|
+
if (!event.isCurrent) return
|
|
1043
|
+
const name = event.provider === 'codex' ? 'Codex' : 'Claude'
|
|
1044
|
+
companionNotice(
|
|
1045
|
+
event.confirmed ? `${name} · budget is back!` : `${name} · reset time reached`,
|
|
1046
|
+
event.provider,
|
|
1047
|
+
)
|
|
1048
|
+
if (event.confirmed) celebrate()
|
|
1049
|
+
})
|
|
1050
|
+
function reactToProvider(provider, data) {
|
|
1051
|
+
// no caption: the eyes glance toward whichever service changed
|
|
1052
|
+
const cue = activityTracker.observe(provider, data)
|
|
1053
|
+
if (!cue || document.body.classList.contains('settings-open')) return
|
|
1054
|
+
if (!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
|
|
1055
|
+
const x = provider === 'codex' ? 3 : -3
|
|
1056
|
+
el('eyes').animate(
|
|
1057
|
+
[
|
|
1058
|
+
{ transform: 'translateX(0)' },
|
|
1059
|
+
{ transform: `translateX(${x}px)`, offset: 0.3 },
|
|
1060
|
+
{ transform: `translateX(${x}px)`, offset: 0.7 },
|
|
1061
|
+
{ transform: 'translateX(0)' },
|
|
1062
|
+
],
|
|
1063
|
+
{ duration: 1100, easing: 'ease-in-out' },
|
|
1064
|
+
)
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function paintActivity() {
|
|
1069
|
+
const activity = Companion.currentActivity(
|
|
1070
|
+
claudeOn() ? claudeActivityData : null,
|
|
1071
|
+
codexOn() ? codexData : null,
|
|
1072
|
+
)
|
|
1073
|
+
const names = activity.providers.map((p) => (p === 'claude' ? 'Claude' : 'Codex')).join(' + ')
|
|
1074
|
+
const label = names ? `${names} · ${activity.activity}` : ''
|
|
1075
|
+
for (const id of ['pet-activity', 'mini-workers']) {
|
|
1076
|
+
const badge = el(id)
|
|
1077
|
+
badge.hidden = !names
|
|
1078
|
+
badge.setAttribute('aria-label', label)
|
|
1079
|
+
for (const logo of badge.querySelectorAll('[data-provider]')) {
|
|
1080
|
+
logo.hidden = !activity.providers.includes(logo.dataset.provider)
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
el('mini-dot').hidden =
|
|
1084
|
+
activity.providers.length > 0 && document.body.classList.contains('collapsed')
|
|
1085
|
+
return activity
|
|
1086
|
+
}
|
|
1087
|
+
|
|
632
1088
|
// main render
|
|
633
1089
|
let prevState = null
|
|
634
1090
|
const prevPct = {} // per service: a switch must not read as a reset
|
|
635
1091
|
let lastData = null
|
|
1092
|
+
// Claude's activity, kept apart from lastData: an account switch or a logout
|
|
1093
|
+
// must drop the old account's worker at once, not a tick later
|
|
1094
|
+
let claudeActivityData = null
|
|
636
1095
|
function render(d) {
|
|
1096
|
+
if (claudeOn()) reactToProvider('claude', d)
|
|
637
1097
|
lastData = d
|
|
1098
|
+
claudeActivityData = claudeOn() ? d : null
|
|
1099
|
+
noteSession(d)
|
|
638
1100
|
// the burn trail is Claude's, fed on every Claude tick whatever is on screen
|
|
639
1101
|
const cl = realUsage
|
|
640
1102
|
burn.note(d.session.tokens, !!cl && cl.session.resetMs != null)
|
|
@@ -647,6 +1109,7 @@ function paint() {
|
|
|
647
1109
|
if (!d0) return
|
|
648
1110
|
const view = viewProvider()
|
|
649
1111
|
paintServices(view)
|
|
1112
|
+
paintCompanion(view)
|
|
650
1113
|
const isCodex = view === 'codex' && !!codexData
|
|
651
1114
|
const cv = isCodex ? codexView(codexData) : null
|
|
652
1115
|
const d = isCodex ? cv.d : d0
|
|
@@ -674,6 +1137,12 @@ function paint() {
|
|
|
674
1137
|
: 'idle'
|
|
675
1138
|
const acts = ['editing', 'reading', 'planning', 'running', 'researching', 'delegating', 'waiting']
|
|
676
1139
|
let curActivity = d.activity
|
|
1140
|
+
const activity = paintActivity()
|
|
1141
|
+
const collapsedNow = document.body.classList.contains('collapsed')
|
|
1142
|
+
if (collapsedNow) {
|
|
1143
|
+
st = activity.providers.length ? 'working' : activity.sleeping ? 'sleeping' : 'idle'
|
|
1144
|
+
curActivity = activity.providers.length ? activity.activity : null
|
|
1145
|
+
}
|
|
677
1146
|
// dev override from `./pet <state>` (base states or an activity name)
|
|
678
1147
|
if (debugState) {
|
|
679
1148
|
const map = {
|
|
@@ -697,6 +1166,7 @@ function paint() {
|
|
|
697
1166
|
for (const a of acts) {
|
|
698
1167
|
document.body.classList.toggle(`act-${a}`, st === 'working' && curActivity === a)
|
|
699
1168
|
}
|
|
1169
|
+
const before = prevState
|
|
700
1170
|
if (prevState && prevState !== st) {
|
|
701
1171
|
if (st === 'sleeping') oneShot('drowse', 700)
|
|
702
1172
|
else if (prevState === 'sleeping') oneShot('wake', 700)
|
|
@@ -721,7 +1191,11 @@ function paint() {
|
|
|
721
1191
|
: `${fmtTokens(d.today.tokens)} tokens today`
|
|
722
1192
|
|
|
723
1193
|
const was = prevPct[view]
|
|
724
|
-
if (liveOn && was != null && sessActive && sessPct != null && was - sessPct > 25)
|
|
1194
|
+
if (liveOn && was != null && sessActive && sessPct != null && was - sessPct > 25) {
|
|
1195
|
+
celebrate()
|
|
1196
|
+
// Codex's own reset is called out by listenCodex, on screen or not
|
|
1197
|
+
if (!isCodex) say(Voice.recapLine(takeRecap(was), fmtTokens, fmtReset), { headline: true })
|
|
1198
|
+
}
|
|
725
1199
|
prevPct[view] = sessPct
|
|
726
1200
|
el('session-pct').textContent = pctText(liveOn ? sessPct : 0)
|
|
727
1201
|
setLevel(el('session-pct'), liveOn ? sp : 0)
|
|
@@ -775,7 +1249,9 @@ function paint() {
|
|
|
775
1249
|
el('month-total').textContent = d.monthTokens == null ? '—' : `${fmtTokens(d.monthTokens)} tokens`
|
|
776
1250
|
paintChip()
|
|
777
1251
|
|
|
778
|
-
|
|
1252
|
+
// compact eats at Claude's pace only while Claude is one of the workers
|
|
1253
|
+
const claudeRate = !collapsedNow || activity.providers.includes('claude')
|
|
1254
|
+
currentRate = claudeRate ? (collapsedNow ? d0 : d).tokensPerMin || 0 : 0
|
|
779
1255
|
if (st === 'working') {
|
|
780
1256
|
if (!eating) {
|
|
781
1257
|
eating = true
|
|
@@ -786,6 +1262,7 @@ function paint() {
|
|
|
786
1262
|
stopEating()
|
|
787
1263
|
}
|
|
788
1264
|
|
|
1265
|
+
listen(before, st, { isCodex, liveOn, sp, sessReset, proj })
|
|
789
1266
|
fitSize()
|
|
790
1267
|
}
|
|
791
1268
|
|
|
@@ -798,7 +1275,8 @@ function fitSize() {
|
|
|
798
1275
|
const zoom = Number.parseFloat(document.body.style.zoom) || 1
|
|
799
1276
|
// The dual-service dock trades height for a little horizontal room.
|
|
800
1277
|
const dual = document.body.classList.contains('dual')
|
|
801
|
-
const
|
|
1278
|
+
const petOnly = document.body.classList.contains('pet-only')
|
|
1279
|
+
const w = (petOnly ? 168 : collapsed ? (dual ? 240 : 192) : 304) * zoom
|
|
802
1280
|
// offsetHeight never reflects a CSS zoom applied to an ancestor (confirmed
|
|
803
1281
|
// empirically against this Electron build) — so measure the unzoomed content
|
|
804
1282
|
// height, then scale the whole thing (content + margin) ourselves
|
|
@@ -819,11 +1297,13 @@ function applyZoom(z) {
|
|
|
819
1297
|
|
|
820
1298
|
let currentConfig = {}
|
|
821
1299
|
let realUsage = null
|
|
1300
|
+
let realUsageReadAt = 0
|
|
822
1301
|
let debugState = null
|
|
823
1302
|
window.api.onDebugState((o) => {
|
|
824
1303
|
const s = o?.state
|
|
825
1304
|
if (s === 'poke') return pokePet()
|
|
826
1305
|
if (s === 'celebrate') return celebrate()
|
|
1306
|
+
if (s === 'say') return sayDebug(o.kind)
|
|
827
1307
|
debugState = s === 'auto' || s === 'clear' || !s ? null : s
|
|
828
1308
|
if (lastData) render(lastData)
|
|
829
1309
|
})
|
|
@@ -837,22 +1317,39 @@ window.api.onConfig((cfg) => {
|
|
|
837
1317
|
document.body.classList.toggle('is-menubar', currentConfig.mode === 'menubar')
|
|
838
1318
|
document.body.classList.toggle('codex-on', !!currentConfig.codex)
|
|
839
1319
|
if (!currentConfig.codex) codexData = null
|
|
1320
|
+
if (!currentConfig.codex) activityTracker.forget('codex')
|
|
1321
|
+
if (!sizeLoaded) {
|
|
1322
|
+
sizeLoaded = true
|
|
1323
|
+
let size = 'expanded'
|
|
1324
|
+
try {
|
|
1325
|
+
size = localStorage.getItem(SIZE_KEY) || size
|
|
1326
|
+
} catch {}
|
|
1327
|
+
setDisplaySize(size, false)
|
|
1328
|
+
} else if (currentConfig.mode === 'menubar' && document.body.classList.contains('pet-only')) {
|
|
1329
|
+
setDisplaySize('compact', false)
|
|
1330
|
+
}
|
|
1331
|
+
heardConfig = true
|
|
1332
|
+
paintMute()
|
|
840
1333
|
paint()
|
|
841
1334
|
applyZoom(currentConfig.zoom)
|
|
842
1335
|
fitSize()
|
|
843
1336
|
})
|
|
844
1337
|
window.api.onCodex((c) => {
|
|
845
1338
|
codexData = c || null
|
|
1339
|
+
reactToProvider('codex', codexOn() ? c : null)
|
|
846
1340
|
paint()
|
|
847
1341
|
})
|
|
848
1342
|
window.api.onRealUsage((u) => {
|
|
849
1343
|
realUsage = u || null
|
|
1344
|
+
realUsageReadAt = u ? Date.now() : 0
|
|
850
1345
|
if (lastData) render(lastData)
|
|
851
1346
|
})
|
|
852
1347
|
window.api.onAuthState((s) => {
|
|
853
1348
|
const on = !!s?.connected
|
|
854
1349
|
document.body.classList.toggle('auth-on', on)
|
|
855
1350
|
if (!on) {
|
|
1351
|
+
activityTracker.forget('claude')
|
|
1352
|
+
claudeActivityData = null
|
|
856
1353
|
realUsage = null
|
|
857
1354
|
document.body.classList.remove('live')
|
|
858
1355
|
// main sends this before it opens the browser for a new login, so the
|
|
@@ -1062,8 +1559,13 @@ function switchAccount(id) {
|
|
|
1062
1559
|
}
|
|
1063
1560
|
|
|
1064
1561
|
window.api.onAccounts((a) => {
|
|
1562
|
+
if (lastAccounts?.active !== a?.active) {
|
|
1563
|
+
activityTracker.forget('claude')
|
|
1564
|
+
claudeActivityData = null
|
|
1565
|
+
}
|
|
1065
1566
|
pendingRemove = null
|
|
1066
1567
|
renderAccounts(a)
|
|
1568
|
+
paint()
|
|
1067
1569
|
})
|
|
1068
1570
|
|
|
1069
1571
|
// closing the panel while a login is pending gives up on it: main puts us back
|
|
@@ -1307,6 +1809,8 @@ function snapshotSettings() {
|
|
|
1307
1809
|
t2: el('set-t2').value,
|
|
1308
1810
|
fire: el('set-fire').value,
|
|
1309
1811
|
zoom: el('set-zoom').value,
|
|
1812
|
+
talk: el('set-talk').checked,
|
|
1813
|
+
sound: el('set-sound').checked,
|
|
1310
1814
|
})
|
|
1311
1815
|
}
|
|
1312
1816
|
function refreshSaveDirty() {
|
|
@@ -1325,6 +1829,9 @@ function clearSaveDirty() {
|
|
|
1325
1829
|
function reflectAlertsOn() {
|
|
1326
1830
|
el('set-rows').classList.toggle('alerts-off', !el('set-alerts').checked)
|
|
1327
1831
|
}
|
|
1832
|
+
function reflectTalkOn() {
|
|
1833
|
+
el('set-rows').classList.toggle('talk-off', !el('set-talk').checked)
|
|
1834
|
+
}
|
|
1328
1835
|
// zoom applies as you step it, so the widget shows the size right away;
|
|
1329
1836
|
// Cancel puts the saved value back
|
|
1330
1837
|
function previewZoom() {
|
|
@@ -1344,10 +1851,14 @@ function populateSettings() {
|
|
|
1344
1851
|
el('set-t2').value = th[1] != null ? th[1] : 95
|
|
1345
1852
|
el('set-fire').value = c.fireThreshold != null ? c.fireThreshold : 90
|
|
1346
1853
|
el('set-zoom').value = c.zoom != null ? c.zoom : 100
|
|
1854
|
+
el('set-talk').checked = c.talk !== false
|
|
1855
|
+
el('set-sound').checked = !!c.sound
|
|
1856
|
+
reflectTalkOn()
|
|
1347
1857
|
clearSaveDirty() // fields now match the saved config
|
|
1348
1858
|
}
|
|
1349
1859
|
function openSettings() {
|
|
1350
|
-
|
|
1860
|
+
hideBubble()
|
|
1861
|
+
setDisplaySize('expanded', false)
|
|
1351
1862
|
populateSettings()
|
|
1352
1863
|
document.body.classList.add('settings-open')
|
|
1353
1864
|
fitSize()
|
|
@@ -1388,11 +1899,24 @@ for (const b of document.querySelectorAll('.num-btn')) {
|
|
|
1388
1899
|
})
|
|
1389
1900
|
}
|
|
1390
1901
|
// light up Save whenever an editable field changes
|
|
1391
|
-
for (const id of [
|
|
1902
|
+
for (const id of [
|
|
1903
|
+
'set-alerts',
|
|
1904
|
+
'set-t1',
|
|
1905
|
+
'set-t2',
|
|
1906
|
+
'set-fire',
|
|
1907
|
+
'set-zoom',
|
|
1908
|
+
'set-talk',
|
|
1909
|
+
'set-sound',
|
|
1910
|
+
]) {
|
|
1392
1911
|
el(id).addEventListener('input', refreshSaveDirty)
|
|
1393
1912
|
el(id).addEventListener('change', refreshSaveDirty)
|
|
1394
1913
|
}
|
|
1395
1914
|
el('set-alerts').addEventListener('change', reflectAlertsOn)
|
|
1915
|
+
el('set-talk').addEventListener('change', reflectTalkOn)
|
|
1916
|
+
// turning the voice on plays a sample, so you hear what you just agreed to
|
|
1917
|
+
el('set-sound').addEventListener('change', () => {
|
|
1918
|
+
if (el('set-sound').checked) blipper.play('Hello there!')
|
|
1919
|
+
})
|
|
1396
1920
|
el('set-zoom').addEventListener('input', previewZoom)
|
|
1397
1921
|
// display-mode segmented control (floating pet | menu bar)
|
|
1398
1922
|
for (const b of document.querySelectorAll('#set-mode .seg-btn')) {
|
|
@@ -1417,23 +1941,28 @@ el('set-save').addEventListener('click', () => {
|
|
|
1417
1941
|
.sort((a, b) => a - b),
|
|
1418
1942
|
fireThreshold: fire >= 1 && fire <= 99 ? fire : 90,
|
|
1419
1943
|
zoom,
|
|
1944
|
+
talk: el('set-talk').checked,
|
|
1945
|
+
sound: el('set-sound').checked,
|
|
1420
1946
|
})
|
|
1421
1947
|
clearSaveDirty()
|
|
1422
1948
|
document.body.classList.remove('settings-open')
|
|
1423
1949
|
fitSize()
|
|
1424
1950
|
})
|
|
1425
1951
|
el('min').addEventListener('click', () => {
|
|
1426
|
-
|
|
1427
|
-
|
|
1952
|
+
hideBubble()
|
|
1953
|
+
setDisplaySize(document.body.classList.contains('collapsed') ? 'expanded' : 'compact')
|
|
1428
1954
|
})
|
|
1429
1955
|
// double-click the pet to collapse / expand
|
|
1430
1956
|
el('pet').addEventListener('dblclick', () => {
|
|
1431
|
-
document.body.classList.
|
|
1432
|
-
|
|
1957
|
+
if (document.body.classList.contains('pet-only')) return
|
|
1958
|
+
hideBubble()
|
|
1959
|
+
setDisplaySize(document.body.classList.contains('collapsed') ? 'expanded' : 'compact')
|
|
1433
1960
|
})
|
|
1434
1961
|
|
|
1435
1962
|
// poke the pet -> bouncy squish + hearts
|
|
1436
|
-
el('pet').addEventListener('click', () =>
|
|
1963
|
+
el('pet').addEventListener('click', () => {
|
|
1964
|
+
if (!document.body.classList.contains('pet-only')) pokePet()
|
|
1965
|
+
})
|
|
1437
1966
|
|
|
1438
1967
|
// eyes follow the cursor
|
|
1439
1968
|
const eyesG = el('eyes')
|
|
@@ -1476,5 +2005,13 @@ if (typeof module === 'object' && module.exports) {
|
|
|
1476
2005
|
paint,
|
|
1477
2006
|
pickProvider,
|
|
1478
2007
|
burn,
|
|
2008
|
+
say,
|
|
2009
|
+
hideBubble,
|
|
2010
|
+
soundOn,
|
|
2011
|
+
setDisplaySize,
|
|
2012
|
+
setPetPeek,
|
|
2013
|
+
paintCompanion,
|
|
2014
|
+
activityTracker,
|
|
2015
|
+
blipper,
|
|
1479
2016
|
}
|
|
1480
2017
|
}
|