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/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: the tabs under the pet pick which one owns the chip, the pet, the
477
- // meters, the breakdowns and the Usage arrow.
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) celebrate()
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
- currentRate = d.tokensPerMin || 0
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 w = (collapsed ? (dual ? 240 : 168) : 304) * zoom
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
- document.body.classList.remove('collapsed')
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 ['set-alerts', 'set-t1', 'set-t2', 'set-fire', 'set-zoom']) {
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
- document.body.classList.toggle('collapsed')
1427
- fitSize()
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.toggle('collapsed')
1432
- fitSize()
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', () => pokePet())
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
  }