clauddy 1.23.1 → 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/renderer/pet.js CHANGED
@@ -136,6 +136,9 @@ function fmtTokens(t) {
136
136
  }
137
137
  function fmtReset(ms) {
138
138
  if (!ms || ms <= 0) return 'now'
139
+ // past a day, hours are the useful grain: "6d 16h", not "160h 57m"
140
+ if (ms >= 86400000)
141
+ return `${Math.floor(ms / 86400000)}d ${Math.floor((ms % 86400000) / 3600000)}h`
139
142
  const h = Math.floor(ms / 3600000)
140
143
  const m = Math.floor((ms % 3600000) / 60000)
141
144
  return h > 0 ? `${h}h ${m}m` : `${m}m`
@@ -468,28 +471,474 @@ function celebrate() {
468
471
  for (let i = 0; i < 24; i++) spawnConfetti(i)
469
472
  }
470
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
+
744
+ // ---- services ---------------------------------------------------------------
745
+ // Claude and Codex are monitored side by side, but the panel shows one at a
746
+ // time: the tabs under the pet pick which one owns the chip, the pet, the
747
+ // meters, the breakdowns and the Usage arrow.
748
+ const PROVIDER_KEY = 'clauddy.provider'
749
+ let codexData = null // last Codex payload; null while Codex isn't enabled
750
+ let pickedProvider = (() => {
751
+ try {
752
+ return localStorage.getItem(PROVIDER_KEY) === 'codex' ? 'codex' : 'claude'
753
+ } catch {
754
+ return 'claude'
755
+ }
756
+ })()
757
+
758
+ const claudeOn = () => document.body.classList.contains('auth-on')
759
+ const codexOn = () => !!currentConfig.codex
760
+ const bothOn = () => claudeOn() && codexOn()
761
+ // the service on screen: the pick, unless that one isn't there any more
762
+ function viewProvider() {
763
+ if (!codexOn()) return 'claude'
764
+ if (!claudeOn()) return 'codex'
765
+ return pickedProvider
766
+ }
767
+
768
+ function pickProvider(p) {
769
+ if (p === pickedProvider) return
770
+ pickedProvider = p
771
+ try {
772
+ localStorage.setItem(PROVIDER_KEY, p)
773
+ } catch {}
774
+ paint()
775
+ }
776
+
777
+ const STALE_MS = 15 * 60000
778
+ const CODEX_SLEEP_MS = 5 * 60000 // main's default sleepThresholdMs
779
+ // Codex logs are only written while it runs, so a reading can be hours old
780
+ const codexStale = (c) =>
781
+ !!c && (c.session?.expired || (c.limitsAt != null && Date.now() - c.limitsAt > STALE_MS))
782
+
783
+ // Codex's numbers in the shape the panel draws; its logs have no activity or tok/min
784
+ function codexView(c) {
785
+ const idleFor = c.lastSeen ? Date.now() - c.lastSeen : null
786
+ return {
787
+ d: {
788
+ active: c.active,
789
+ activity: null,
790
+ // no data is not the same as asleep
791
+ sleeping: !c.active && idleFor != null && idleFor >= CODEX_SLEEP_MS,
792
+ tokensPerMin: 0,
793
+ today: { tokens: c.tokensToday || 0 },
794
+ session: { tokens: c.tokens5h || 0 },
795
+ week: { tokens: c.tokensWeek || 0 },
796
+ byModel: c.byModel || [],
797
+ byProject: c.byProject || [],
798
+ days30: c.days30 || [],
799
+ monthTokens: c.monthTokens ?? null,
800
+ },
801
+ sess: c.session,
802
+ week: c.weekly,
803
+ stale: c.limitsAt != null && Date.now() - c.limitsAt > STALE_MS ? c.limitsAt : null,
804
+ }
805
+ }
806
+
807
+ // a Codex meter caption: its reset, or how old the reading is when that's what matters
808
+ function codexSub(w, tokens, stale) {
809
+ const t = `${fmtTokens(tokens)} tokens`
810
+ if (!w) return `limits not recorded yet · ${t}`
811
+ if (w.pct == null) return `waiting for a fresh reading · ${t}`
812
+ if (stale) {
813
+ const at = w.resetMs != null ? `resets ${fmtResetClock(w.resetMs)} · ` : ''
814
+ return `${at}read ${fmtReset(Date.now() - stale)} ago`
815
+ }
816
+ const reset = w.resetMs != null ? `resets in ${fmtResetIn(w.resetMs)} · ` : ''
817
+ return `${reset}${t}`
818
+ }
819
+
820
+ const pctText = (v) => (v == null ? '—' : `${Math.round(v)}%`)
821
+ const warnAt = () => Math.min(...(currentConfig.alertThresholds || [80, 95]))
822
+
823
+ // tabs + the collapsed pair: both services' session %, whichever is on screen
824
+ function paintServices(view) {
825
+ const dual = bothOn()
826
+ document.body.classList.toggle('dual', dual)
827
+ document.body.classList.toggle('view-codex', view === 'codex')
828
+ // collapsed with both: when the selected service's session resets
829
+ const resetMs = view === 'codex' ? codexData?.session?.resetMs : realUsage?.session?.resetMs
830
+ el('mini-reset').hidden = !dual || resetMs == null
831
+ el('mini-reset').textContent = resetMs == null ? '' : `resets ${fmtResetClock(resetMs)}`
832
+ el('service-panel').setAttribute('role', dual ? 'tabpanel' : 'region')
833
+ if (dual) {
834
+ el('service-panel').setAttribute('aria-labelledby', `tab-${view}`)
835
+ el('service-panel').removeAttribute('aria-label')
836
+ } else {
837
+ el('service-panel').removeAttribute('aria-labelledby')
838
+ el('service-panel').setAttribute('aria-label', `${view === 'codex' ? 'Codex' : 'Claude'} usage`)
839
+ return
840
+ }
841
+ const cl = realUsage
842
+ const cx = codexData
843
+ const rows = {
844
+ claude: {
845
+ pct: cl?.session?.pct ?? null,
846
+ week: cl?.week?.pct ?? null,
847
+ active: !!lastData?.active,
848
+ },
849
+ codex: {
850
+ pct: cx?.session?.pct ?? null,
851
+ week: cx?.weekly?.pct ?? null,
852
+ active: !!cx?.active,
853
+ stale: codexStale(cx),
854
+ },
855
+ }
856
+ for (const [p, r] of Object.entries(rows)) {
857
+ const on = p === view
858
+ const urgent = Math.max(r.pct ?? 0, r.week ?? 0) >= warnAt()
859
+ const tab = el(`tab-${p}`)
860
+ tab.setAttribute('aria-selected', String(on))
861
+ tab.tabIndex = on ? 0 : -1
862
+ tab.classList.toggle('active', r.active)
863
+ tab.classList.toggle('urgent', urgent)
864
+ tab.querySelector('.tab-alert').hidden = !urgent
865
+ el(`tab-${p}-value`).textContent = pctText(r.pct)
866
+ const name = p === 'claude' ? 'Claude' : 'Codex'
867
+ tab.title = `${name} session ${pctText(r.pct)} · weekly ${pctText(r.week)}${r.active ? ' · working' : ''}${r.stale ? ' · not updated recently' : ''}`
868
+ const line = document.querySelector(`.mini-source[data-provider="${p}"]`)
869
+ line.setAttribute('aria-pressed', String(on))
870
+ line.title = tab.title
871
+ line.setAttribute('aria-label', `Show ${tab.title}`)
872
+ line.classList.toggle('urgent', urgent)
873
+ tab.setAttribute('aria-label', `Show ${tab.title}`)
874
+ el(`mini-${p}-value`).textContent = pctText(r.pct)
875
+ const bar = el(`mini-${p}-bar`)
876
+ bar.style.width = `${Math.min(r.pct ?? 0, 100)}%`
877
+ bar.classList.toggle('hot', (r.pct ?? 0) >= 80)
878
+ }
879
+ }
880
+
881
+ for (const b of document.querySelectorAll('#harness-tabs button, .mini-source')) {
882
+ b.addEventListener('click', (e) => {
883
+ e.stopPropagation()
884
+ pickProvider(b.dataset.provider)
885
+ })
886
+ }
887
+ el('harness-tabs').addEventListener('keydown', (e) => {
888
+ const providers = ['claude', 'codex']
889
+ const current = providers.indexOf(e.target.dataset.provider)
890
+ if (current < 0) return
891
+ let next
892
+ if (e.key === 'ArrowRight') next = (current + 1) % providers.length
893
+ else if (e.key === 'ArrowLeft') next = (current + providers.length - 1) % providers.length
894
+ else if (e.key === 'Home') next = 0
895
+ else if (e.key === 'End') next = providers.length - 1
896
+ else return
897
+ e.preventDefault()
898
+ pickProvider(providers[next])
899
+ el(`tab-${providers[next]}`).focus()
900
+ })
901
+
471
902
  // main render
472
903
  let prevState = null
473
- let prevPct = null
904
+ const prevPct = {} // per service: a switch must not read as a reset
474
905
  let lastData = null
475
906
  function render(d) {
476
907
  lastData = d
477
- // % comes only from the connected account — no estimates
478
- const liveOn = !!realUsage
908
+ noteSession(d)
909
+ // the burn trail is Claude's, fed on every Claude tick whatever is on screen
910
+ const cl = realUsage
911
+ burn.note(d.session.tokens, !!cl && cl.session.resetMs != null)
912
+ paint()
913
+ }
914
+
915
+ function paint() {
916
+ setPlan(el('codex-plan'), codexData?.plan)
917
+ const d0 = lastData
918
+ if (!d0) return
919
+ const view = viewProvider()
920
+ paintServices(view)
921
+ const isCodex = view === 'codex' && !!codexData
922
+ const cv = isCodex ? codexView(codexData) : null
923
+ const d = isCodex ? cv.d : d0
924
+ // % comes only from the connected account (or Codex's own logs) — no estimates
925
+ const liveOn = isCodex || !!realUsage
479
926
  document.body.classList.toggle('live', liveOn)
480
- const sessPct = liveOn ? realUsage.session.pct : 0
481
- const sessReset = liveOn ? realUsage.session.resetMs : null
927
+ const sessPct = isCodex ? (cv.sess?.pct ?? null) : liveOn ? realUsage.session.pct : 0
928
+ const sessReset = isCodex ? (cv.sess?.resetMs ?? null) : liveOn ? realUsage.session.resetMs : null
482
929
  const sessActive = liveOn && sessReset != null
483
- const wkPct = liveOn ? realUsage.week.pct : 0
484
- const wkReset = liveOn ? realUsage.week.resetMs : null
930
+ const wkPct = isCodex ? (cv.week?.pct ?? null) : liveOn ? realUsage.week.pct : 0
931
+ const wkReset = isCodex ? (cv.week?.resetMs ?? null) : liveOn ? realUsage.week.resetMs : null
932
+ const sp = sessPct ?? 0
933
+ const wp = wkPct ?? 0
485
934
 
486
935
  const fireAt = currentConfig.fireThreshold ?? 90
487
936
  let st =
488
- liveOn && sessPct >= 100
937
+ liveOn && sp >= 100
489
938
  ? 'tired'
490
939
  : d.active
491
940
  ? 'working'
492
- : liveOn && sessPct >= fireAt
941
+ : liveOn && sp >= fireAt
493
942
  ? 'stressed'
494
943
  : d.sleeping
495
944
  ? 'sleeping'
@@ -519,6 +968,7 @@ function render(d) {
519
968
  for (const a of acts) {
520
969
  document.body.classList.toggle(`act-${a}`, st === 'working' && curActivity === a)
521
970
  }
971
+ const before = prevState
522
972
  if (prevState && prevState !== st) {
523
973
  if (st === 'sleeping') oneShot('drowse', 700)
524
974
  else if (prevState === 'sleeping') oneShot('wake', 700)
@@ -542,32 +992,38 @@ function render(d) {
542
992
  ? `${fmtTokens(d.tokensPerMin)} tok/min`
543
993
  : `${fmtTokens(d.today.tokens)} tokens today`
544
994
 
545
- if (liveOn && prevPct != null && sessActive && prevPct - sessPct > 25) celebrate()
546
- prevPct = sessPct
547
- el('session-pct').textContent = `${Math.round(sessPct)}%`
548
- setLevel(el('session-pct'), liveOn ? sessPct : 0)
995
+ const was = prevPct[view]
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
+ }
1001
+ prevPct[view] = sessPct
1002
+ el('session-pct').textContent = pctText(liveOn ? sessPct : 0)
1003
+ setLevel(el('session-pct'), liveOn ? sp : 0)
549
1004
  const mini = el('mini-pct')
550
- mini.textContent = liveOn ? `${Math.round(sessPct)}%` : '—'
551
- mini.classList.toggle('high', liveOn && sessPct >= 80)
1005
+ mini.textContent = liveOn ? pctText(sessPct) : '—'
1006
+ mini.classList.toggle('high', liveOn && sp >= 80)
552
1007
  // the collapsed ring: how much of the session is already spent
553
1008
  const rf = el('ring-fill')
554
- rf.style.strokeDashoffset = `${RING_LEN * (1 - Math.min(sessPct, 100) / 100)}`
555
- rf.classList.toggle('high', sessPct >= 80)
1009
+ rf.style.strokeDashoffset = `${RING_LEN * (1 - Math.min(sp, 100) / 100)}`
1010
+ rf.classList.toggle('high', sp >= 80)
556
1011
  const rp = el('ring-pct')
557
1012
  rp.textContent = mini.textContent
558
- rp.classList.toggle('high', liveOn && sessPct >= 80)
1013
+ rp.classList.toggle('high', liveOn && sp >= 80)
559
1014
  const sf = el('session-fill')
560
- sf.style.width = `${sessPct}%`
561
- sf.classList.toggle('high', sessPct >= 80)
562
- setLevel(sf, sessPct)
563
- el('session-sub').textContent = sessActive
564
- ? `resets in ${fmtResetIn(sessReset)} · ${fmtTokens(d.session.tokens)} tokens`
565
- : 'no active session'
566
-
567
- // where this pace is taking you — hidden until there's enough trail to tell
568
- burn.note(d.session.tokens, sessActive)
1015
+ sf.style.width = `${sp}%`
1016
+ sf.classList.toggle('high', sp >= 80)
1017
+ setLevel(sf, sp)
1018
+ el('session-sub').textContent = isCodex
1019
+ ? codexSub(cv.sess, d.session.tokens, cv.stale)
1020
+ : sessActive
1021
+ ? `resets in ${fmtResetIn(sessReset)} · ${fmtTokens(d.session.tokens)} tokens`
1022
+ : 'no active session'
1023
+
1024
+ // where this pace is taking you — Claude's only: Codex has no trail to read
569
1025
  const proj =
570
- sessActive && sessPct < 100 ? burn.project(sessPct, sessReset, d.session.tokens) : null
1026
+ !isCodex && sessActive && sp < 100 ? burn.project(sp, sessReset, d.session.tokens) : null
571
1027
  const pe = el('session-proj')
572
1028
  pe.hidden = !proj
573
1029
  pe.classList.toggle('tight', proj?.kind === 'eta')
@@ -576,22 +1032,24 @@ function render(d) {
576
1032
  proj.kind === 'eta' ? `~${fmtReset(proj.ms)} left at this pace` : 'resets before you run out'
577
1033
  }
578
1034
 
579
- el('week-pct').textContent = `${Math.round(wkPct)}%`
580
- setLevel(el('week-pct'), wkPct)
1035
+ el('week-pct').textContent = pctText(liveOn ? wkPct : 0)
1036
+ setLevel(el('week-pct'), wp)
581
1037
  const wf = el('week-fill')
582
- wf.style.width = `${wkPct}%`
583
- wf.classList.toggle('high', wkPct >= 80)
584
- setLevel(wf, wkPct)
585
- el('week-sub').textContent =
586
- wkReset != null
1038
+ wf.style.width = `${wp}%`
1039
+ wf.classList.toggle('high', wp >= 80)
1040
+ setLevel(wf, wp)
1041
+ el('week-sub').textContent = isCodex
1042
+ ? codexSub(cv.week, d.week.tokens, cv.stale)
1043
+ : wkReset != null
587
1044
  ? `resets in ${fmtResetIn(wkReset)} · ${fmtTokens(d.week.tokens)} tokens`
588
1045
  : `${fmtTokens(d.week.tokens)} tokens · last 7 days`
589
1046
 
590
- renderScoped(liveOn ? realUsage.scoped || [] : [], d.byModel || [])
1047
+ renderScoped(!isCodex && liveOn ? realUsage.scoped || [] : [], d.byModel || [])
591
1048
  renderModels(d.byModel || [])
592
1049
  renderProjects(d.byProject || [])
593
1050
  renderHeat(d.days30 || [])
594
- el('month-total').textContent = `${fmtTokens(d.monthTokens)} tokens`
1051
+ el('month-total').textContent = d.monthTokens == null ? '—' : `${fmtTokens(d.monthTokens)} tokens`
1052
+ paintChip()
595
1053
 
596
1054
  currentRate = d.tokensPerMin || 0
597
1055
  if (st === 'working') {
@@ -604,6 +1062,7 @@ function render(d) {
604
1062
  stopEating()
605
1063
  }
606
1064
 
1065
+ listen(before, st, { isCodex, liveOn, sp, sessReset, proj })
607
1066
  fitSize()
608
1067
  }
609
1068
 
@@ -614,7 +1073,9 @@ function fitSize() {
614
1073
  requestAnimationFrame(() => {
615
1074
  const collapsed = document.body.classList.contains('collapsed')
616
1075
  const zoom = Number.parseFloat(document.body.style.zoom) || 1
617
- const w = (collapsed ? 168 : 276) * zoom
1076
+ // The dual-service dock trades height for a little horizontal room.
1077
+ const dual = document.body.classList.contains('dual')
1078
+ const w = (collapsed ? (dual ? 240 : 168) : 304) * zoom
618
1079
  // offsetHeight never reflects a CSS zoom applied to an ancestor (confirmed
619
1080
  // empirically against this Electron build) — so measure the unzoomed content
620
1081
  // height, then scale the whole thing (content + margin) ourselves
@@ -640,6 +1101,7 @@ window.api.onDebugState((o) => {
640
1101
  const s = o?.state
641
1102
  if (s === 'poke') return pokePet()
642
1103
  if (s === 'celebrate') return celebrate()
1104
+ if (s === 'say') return sayDebug(o.kind)
643
1105
  debugState = s === 'auto' || s === 'clear' || !s ? null : s
644
1106
  if (lastData) render(lastData)
645
1107
  })
@@ -651,9 +1113,18 @@ window.api.onError((msg) => {
651
1113
  window.api.onConfig((cfg) => {
652
1114
  currentConfig = cfg || {}
653
1115
  document.body.classList.toggle('is-menubar', currentConfig.mode === 'menubar')
1116
+ document.body.classList.toggle('codex-on', !!currentConfig.codex)
1117
+ if (!currentConfig.codex) codexData = null
1118
+ heardConfig = true
1119
+ paintMute()
1120
+ paint()
654
1121
  applyZoom(currentConfig.zoom)
655
1122
  fitSize()
656
1123
  })
1124
+ window.api.onCodex((c) => {
1125
+ codexData = c || null
1126
+ paint()
1127
+ })
657
1128
  window.api.onRealUsage((u) => {
658
1129
  realUsage = u || null
659
1130
  if (lastData) render(lastData)
@@ -669,6 +1140,7 @@ window.api.onAuthState((s) => {
669
1140
  endLogin()
670
1141
  showProfile(null)
671
1142
  }
1143
+ paint() // losing Claude can leave Codex as the only service
672
1144
  if (document.body.classList.contains('settings-open')) fitSize()
673
1145
  })
674
1146
 
@@ -689,12 +1161,24 @@ function paintChip() {
689
1161
  const active = (a?.accounts || []).find((x) => x.id === a?.active)
690
1162
  const email = p?.email || (active?.connected ? active.label : null)
691
1163
  // the settings block says who is connected, not just that someone is: the
692
- // avatar carries the initial, the name line the email, the sub line the plan
1164
+ // logo identifies the service, followed by the same plan badge and email.
693
1165
  el('acc-ok').textContent = email || 'Connected'
694
- el('acc-sub').textContent = p?.plan || 'your real usage is live'
695
- el('acc-avatar').textContent = email ? email[0].toUpperCase() : '●'
1166
+ setPlan(el('acc-sub'), p?.plan)
1167
+ el('acc-ok').title = email || 'Connected'
696
1168
  const chip = el('account-chip')
697
1169
  const mini = el('mini-acct')
1170
+ // the Codex view never borrows Claude's identity: its logs carry no email
1171
+ if (viewProvider() === 'codex') {
1172
+ const plan = codexData?.plan || null
1173
+ el('ac-email').textContent = 'Codex'
1174
+ chip.title = 'Codex on this computer'
1175
+ setPlan(el('ac-plan'), plan)
1176
+ chip.hidden = false
1177
+ el('mini-acct-name').textContent = 'Codex'
1178
+ setPlan(el('mini-acct-plan'), plan)
1179
+ mini.hidden = false
1180
+ return
1181
+ }
698
1182
  if (!email) {
699
1183
  chip.hidden = true
700
1184
  mini.hidden = true
@@ -832,6 +1316,8 @@ function toggleAccountMenu() {
832
1316
 
833
1317
  el('account-chip').addEventListener('click', (e) => {
834
1318
  e.stopPropagation()
1319
+ // Codex has no account switcher: its chip leads to its Settings block
1320
+ if (viewProvider() === 'codex') return openSettings()
835
1321
  toggleAccountMenu()
836
1322
  })
837
1323
  // clicking anywhere else — the pet, the gear, another app — puts it away
@@ -890,7 +1376,7 @@ window.api.onAuthResult((r) => {
890
1376
  }, 6000)
891
1377
  celebrate()
892
1378
  } else {
893
- // failed: put "Log in with browser" back, so a retry is one click away
1379
+ // failed: restore the connection card so a retry is one click away
894
1380
  document.body.classList.remove('awaiting')
895
1381
  const e = r?.error || ''
896
1382
  el('acc-msg').textContent = /429|rate_limit/i.test(e)
@@ -900,6 +1386,26 @@ window.api.onAuthResult((r) => {
900
1386
  fitSize()
901
1387
  })
902
1388
 
1389
+ // Keep the main view focused on limits; history is an explicit, remembered choice.
1390
+ const DETAILS_KEY = 'clauddy.details-open'
1391
+ function setDetailsOpen(open) {
1392
+ el('usage-details').hidden = !open
1393
+ el('details-toggle').setAttribute('aria-expanded', String(open))
1394
+ fitSize()
1395
+ }
1396
+ try {
1397
+ setDetailsOpen(localStorage.getItem(DETAILS_KEY) === 'true')
1398
+ } catch {
1399
+ setDetailsOpen(false)
1400
+ }
1401
+ el('details-toggle').addEventListener('click', () => {
1402
+ const open = el('usage-details').hidden
1403
+ setDetailsOpen(open)
1404
+ try {
1405
+ localStorage.setItem(DETAILS_KEY, String(open))
1406
+ } catch {}
1407
+ })
1408
+
903
1409
  // collapsible panel sections — the widget is a desktop pet, not a dashboard, so
904
1410
  // each breakdown can be folded away and the choice is remembered per machine
905
1411
  const SEC_KEY = 'clauddy.folded'
@@ -955,7 +1461,36 @@ for (const head of document.querySelectorAll('.sec-head')) {
955
1461
  for (const id of readFolded()) toggleSection(id, true)
956
1462
 
957
1463
  el('close').addEventListener('click', () => window.api.quit())
958
- el('usage').addEventListener('click', () => window.api.openUsage())
1464
+ el('usage').addEventListener('click', () => window.api.openUsage(viewProvider()))
1465
+
1466
+ // Settings → Codex: Connect looks for a local session and turns monitoring on if found
1467
+ function endCodexSetup() {
1468
+ document.body.classList.remove('codex-setup')
1469
+ el('codex-setup').hidden = true
1470
+ el('codex-hint').textContent = 'Track Codex alongside Claude, in this pet.'
1471
+ }
1472
+ el('codex-connect').addEventListener('click', () => {
1473
+ el('codex-hint').textContent = 'Looking…'
1474
+ window.api.codexDetect()
1475
+ })
1476
+ el('codex-retry').addEventListener('click', () => window.api.codexDetect())
1477
+ // Connect is the consent: found → monitoring starts, not found → say why
1478
+ window.api.onCodexDetected((r) => {
1479
+ if (r?.found) {
1480
+ endCodexSetup()
1481
+ window.api.codexEnable(true)
1482
+ return
1483
+ }
1484
+ document.body.classList.add('codex-setup')
1485
+ el('codex-setup').hidden = false
1486
+ el('codex-hint').textContent = 'No session — sign in to Codex'
1487
+ fitSize()
1488
+ })
1489
+ el('codex-setup-cancel').addEventListener('click', () => {
1490
+ endCodexSetup()
1491
+ fitSize()
1492
+ })
1493
+ el('codex-disconnect').addEventListener('click', () => window.api.codexEnable(false))
959
1494
 
960
1495
  // account login (browser flow)
961
1496
  el('acc-connect').addEventListener('click', () => window.api.authStart())
@@ -1052,6 +1587,8 @@ function snapshotSettings() {
1052
1587
  t2: el('set-t2').value,
1053
1588
  fire: el('set-fire').value,
1054
1589
  zoom: el('set-zoom').value,
1590
+ talk: el('set-talk').checked,
1591
+ sound: el('set-sound').checked,
1055
1592
  })
1056
1593
  }
1057
1594
  function refreshSaveDirty() {
@@ -1070,6 +1607,9 @@ function clearSaveDirty() {
1070
1607
  function reflectAlertsOn() {
1071
1608
  el('set-rows').classList.toggle('alerts-off', !el('set-alerts').checked)
1072
1609
  }
1610
+ function reflectTalkOn() {
1611
+ el('set-rows').classList.toggle('talk-off', !el('set-talk').checked)
1612
+ }
1073
1613
  // zoom applies as you step it, so the widget shows the size right away;
1074
1614
  // Cancel puts the saved value back
1075
1615
  function previewZoom() {
@@ -1089,9 +1629,13 @@ function populateSettings() {
1089
1629
  el('set-t2').value = th[1] != null ? th[1] : 95
1090
1630
  el('set-fire').value = c.fireThreshold != null ? c.fireThreshold : 90
1091
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()
1092
1635
  clearSaveDirty() // fields now match the saved config
1093
1636
  }
1094
1637
  function openSettings() {
1638
+ hideBubble()
1095
1639
  document.body.classList.remove('collapsed')
1096
1640
  populateSettings()
1097
1641
  document.body.classList.add('settings-open')
@@ -1105,6 +1649,7 @@ let closingTimer = null
1105
1649
  function closeSettings() {
1106
1650
  if (!document.body.classList.contains('settings-open')) return
1107
1651
  abandonLogin()
1652
+ endCodexSetup()
1108
1653
  document.body.classList.remove('settings-open')
1109
1654
  document.body.classList.add('settings-closing')
1110
1655
  clearTimeout(closingTimer)
@@ -1132,11 +1677,24 @@ for (const b of document.querySelectorAll('.num-btn')) {
1132
1677
  })
1133
1678
  }
1134
1679
  // light up Save whenever an editable field changes
1135
- for (const id of ['set-alerts', 'set-t1', 'set-t2', 'set-fire', 'set-zoom']) {
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
+ ]) {
1136
1689
  el(id).addEventListener('input', refreshSaveDirty)
1137
1690
  el(id).addEventListener('change', refreshSaveDirty)
1138
1691
  }
1139
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
+ })
1140
1698
  el('set-zoom').addEventListener('input', previewZoom)
1141
1699
  // display-mode segmented control (floating pet | menu bar)
1142
1700
  for (const b of document.querySelectorAll('#set-mode .seg-btn')) {
@@ -1161,17 +1719,21 @@ el('set-save').addEventListener('click', () => {
1161
1719
  .sort((a, b) => a - b),
1162
1720
  fireThreshold: fire >= 1 && fire <= 99 ? fire : 90,
1163
1721
  zoom,
1722
+ talk: el('set-talk').checked,
1723
+ sound: el('set-sound').checked,
1164
1724
  })
1165
1725
  clearSaveDirty()
1166
1726
  document.body.classList.remove('settings-open')
1167
1727
  fitSize()
1168
1728
  })
1169
1729
  el('min').addEventListener('click', () => {
1730
+ hideBubble()
1170
1731
  document.body.classList.toggle('collapsed')
1171
1732
  fitSize()
1172
1733
  })
1173
1734
  // double-click the pet to collapse / expand
1174
1735
  el('pet').addEventListener('dblclick', () => {
1736
+ hideBubble()
1175
1737
  document.body.classList.toggle('collapsed')
1176
1738
  fitSize()
1177
1739
  })
@@ -1217,6 +1779,12 @@ if (typeof module === 'object' && module.exports) {
1217
1779
  showProfile,
1218
1780
  renderAccounts,
1219
1781
  renderAccountMenu,
1782
+ paint,
1783
+ pickProvider,
1220
1784
  burn,
1785
+ say,
1786
+ hideBubble,
1787
+ soundOn,
1788
+ blipper,
1221
1789
  }
1222
1790
  }