clauddy 1.23.0 → 1.24.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,203 @@ function celebrate() {
468
471
  for (let i = 0; i < 24; i++) spawnConfetti(i)
469
472
  }
470
473
 
474
+ // ---- services ---------------------------------------------------------------
475
+ // 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.
478
+ const PROVIDER_KEY = 'clauddy.provider'
479
+ let codexData = null // last Codex payload; null while Codex isn't enabled
480
+ let pickedProvider = (() => {
481
+ try {
482
+ return localStorage.getItem(PROVIDER_KEY) === 'codex' ? 'codex' : 'claude'
483
+ } catch {
484
+ return 'claude'
485
+ }
486
+ })()
487
+
488
+ const claudeOn = () => document.body.classList.contains('auth-on')
489
+ const codexOn = () => !!currentConfig.codex
490
+ const bothOn = () => claudeOn() && codexOn()
491
+ // the service on screen: the pick, unless that one isn't there any more
492
+ function viewProvider() {
493
+ if (!codexOn()) return 'claude'
494
+ if (!claudeOn()) return 'codex'
495
+ return pickedProvider
496
+ }
497
+
498
+ function pickProvider(p) {
499
+ if (p === pickedProvider) return
500
+ pickedProvider = p
501
+ try {
502
+ localStorage.setItem(PROVIDER_KEY, p)
503
+ } catch {}
504
+ paint()
505
+ }
506
+
507
+ const STALE_MS = 15 * 60000
508
+ const CODEX_SLEEP_MS = 5 * 60000 // main's default sleepThresholdMs
509
+ // Codex logs are only written while it runs, so a reading can be hours old
510
+ const codexStale = (c) =>
511
+ !!c && (c.session?.expired || (c.limitsAt != null && Date.now() - c.limitsAt > STALE_MS))
512
+
513
+ // Codex's numbers in the shape the panel draws; its logs have no activity or tok/min
514
+ function codexView(c) {
515
+ const idleFor = c.lastSeen ? Date.now() - c.lastSeen : null
516
+ return {
517
+ d: {
518
+ active: c.active,
519
+ activity: null,
520
+ // no data is not the same as asleep
521
+ sleeping: !c.active && idleFor != null && idleFor >= CODEX_SLEEP_MS,
522
+ tokensPerMin: 0,
523
+ today: { tokens: c.tokensToday || 0 },
524
+ session: { tokens: c.tokens5h || 0 },
525
+ week: { tokens: c.tokensWeek || 0 },
526
+ byModel: c.byModel || [],
527
+ byProject: c.byProject || [],
528
+ days30: c.days30 || [],
529
+ monthTokens: c.monthTokens ?? null,
530
+ },
531
+ sess: c.session,
532
+ week: c.weekly,
533
+ stale: c.limitsAt != null && Date.now() - c.limitsAt > STALE_MS ? c.limitsAt : null,
534
+ }
535
+ }
536
+
537
+ // a Codex meter caption: its reset, or how old the reading is when that's what matters
538
+ function codexSub(w, tokens, stale) {
539
+ const t = `${fmtTokens(tokens)} tokens`
540
+ if (!w) return `limits not recorded yet · ${t}`
541
+ if (w.pct == null) return `waiting for a fresh reading · ${t}`
542
+ if (stale) {
543
+ const at = w.resetMs != null ? `resets ${fmtResetClock(w.resetMs)} · ` : ''
544
+ return `${at}read ${fmtReset(Date.now() - stale)} ago`
545
+ }
546
+ const reset = w.resetMs != null ? `resets in ${fmtResetIn(w.resetMs)} · ` : ''
547
+ return `${reset}${t}`
548
+ }
549
+
550
+ const pctText = (v) => (v == null ? '—' : `${Math.round(v)}%`)
551
+ const warnAt = () => Math.min(...(currentConfig.alertThresholds || [80, 95]))
552
+
553
+ // tabs + the collapsed pair: both services' session %, whichever is on screen
554
+ function paintServices(view) {
555
+ const dual = bothOn()
556
+ document.body.classList.toggle('dual', dual)
557
+ document.body.classList.toggle('view-codex', view === 'codex')
558
+ // collapsed with both: when the selected service's session resets
559
+ const resetMs = view === 'codex' ? codexData?.session?.resetMs : realUsage?.session?.resetMs
560
+ el('mini-reset').hidden = !dual || resetMs == null
561
+ el('mini-reset').textContent = resetMs == null ? '' : `resets ${fmtResetClock(resetMs)}`
562
+ el('service-panel').setAttribute('role', dual ? 'tabpanel' : 'region')
563
+ if (dual) {
564
+ el('service-panel').setAttribute('aria-labelledby', `tab-${view}`)
565
+ el('service-panel').removeAttribute('aria-label')
566
+ } else {
567
+ el('service-panel').removeAttribute('aria-labelledby')
568
+ el('service-panel').setAttribute('aria-label', `${view === 'codex' ? 'Codex' : 'Claude'} usage`)
569
+ return
570
+ }
571
+ const cl = realUsage
572
+ const cx = codexData
573
+ const rows = {
574
+ claude: {
575
+ pct: cl?.session?.pct ?? null,
576
+ week: cl?.week?.pct ?? null,
577
+ active: !!lastData?.active,
578
+ },
579
+ codex: {
580
+ pct: cx?.session?.pct ?? null,
581
+ week: cx?.weekly?.pct ?? null,
582
+ active: !!cx?.active,
583
+ stale: codexStale(cx),
584
+ },
585
+ }
586
+ for (const [p, r] of Object.entries(rows)) {
587
+ const on = p === view
588
+ const urgent = Math.max(r.pct ?? 0, r.week ?? 0) >= warnAt()
589
+ const tab = el(`tab-${p}`)
590
+ tab.setAttribute('aria-selected', String(on))
591
+ tab.tabIndex = on ? 0 : -1
592
+ tab.classList.toggle('active', r.active)
593
+ tab.classList.toggle('urgent', urgent)
594
+ tab.querySelector('.tab-alert').hidden = !urgent
595
+ el(`tab-${p}-value`).textContent = pctText(r.pct)
596
+ const name = p === 'claude' ? 'Claude' : 'Codex'
597
+ tab.title = `${name} session ${pctText(r.pct)} · weekly ${pctText(r.week)}${r.active ? ' · working' : ''}${r.stale ? ' · not updated recently' : ''}`
598
+ const line = document.querySelector(`.mini-source[data-provider="${p}"]`)
599
+ line.setAttribute('aria-pressed', String(on))
600
+ line.title = tab.title
601
+ line.setAttribute('aria-label', `Show ${tab.title}`)
602
+ line.classList.toggle('urgent', urgent)
603
+ tab.setAttribute('aria-label', `Show ${tab.title}`)
604
+ el(`mini-${p}-value`).textContent = pctText(r.pct)
605
+ const bar = el(`mini-${p}-bar`)
606
+ bar.style.width = `${Math.min(r.pct ?? 0, 100)}%`
607
+ bar.classList.toggle('hot', (r.pct ?? 0) >= 80)
608
+ }
609
+ }
610
+
611
+ for (const b of document.querySelectorAll('#harness-tabs button, .mini-source')) {
612
+ b.addEventListener('click', (e) => {
613
+ e.stopPropagation()
614
+ pickProvider(b.dataset.provider)
615
+ })
616
+ }
617
+ el('harness-tabs').addEventListener('keydown', (e) => {
618
+ const providers = ['claude', 'codex']
619
+ const current = providers.indexOf(e.target.dataset.provider)
620
+ if (current < 0) return
621
+ let next
622
+ if (e.key === 'ArrowRight') next = (current + 1) % providers.length
623
+ else if (e.key === 'ArrowLeft') next = (current + providers.length - 1) % providers.length
624
+ else if (e.key === 'Home') next = 0
625
+ else if (e.key === 'End') next = providers.length - 1
626
+ else return
627
+ e.preventDefault()
628
+ pickProvider(providers[next])
629
+ el(`tab-${providers[next]}`).focus()
630
+ })
631
+
471
632
  // main render
472
633
  let prevState = null
473
- let prevPct = null
634
+ const prevPct = {} // per service: a switch must not read as a reset
474
635
  let lastData = null
475
636
  function render(d) {
476
637
  lastData = d
477
- // % comes only from the connected account — no estimates
478
- const liveOn = !!realUsage
638
+ // the burn trail is Claude's, fed on every Claude tick whatever is on screen
639
+ const cl = realUsage
640
+ burn.note(d.session.tokens, !!cl && cl.session.resetMs != null)
641
+ paint()
642
+ }
643
+
644
+ function paint() {
645
+ setPlan(el('codex-plan'), codexData?.plan)
646
+ const d0 = lastData
647
+ if (!d0) return
648
+ const view = viewProvider()
649
+ paintServices(view)
650
+ const isCodex = view === 'codex' && !!codexData
651
+ const cv = isCodex ? codexView(codexData) : null
652
+ const d = isCodex ? cv.d : d0
653
+ // % comes only from the connected account (or Codex's own logs) — no estimates
654
+ const liveOn = isCodex || !!realUsage
479
655
  document.body.classList.toggle('live', liveOn)
480
- const sessPct = liveOn ? realUsage.session.pct : 0
481
- const sessReset = liveOn ? realUsage.session.resetMs : null
656
+ const sessPct = isCodex ? (cv.sess?.pct ?? null) : liveOn ? realUsage.session.pct : 0
657
+ const sessReset = isCodex ? (cv.sess?.resetMs ?? null) : liveOn ? realUsage.session.resetMs : null
482
658
  const sessActive = liveOn && sessReset != null
483
- const wkPct = liveOn ? realUsage.week.pct : 0
484
- const wkReset = liveOn ? realUsage.week.resetMs : null
659
+ const wkPct = isCodex ? (cv.week?.pct ?? null) : liveOn ? realUsage.week.pct : 0
660
+ const wkReset = isCodex ? (cv.week?.resetMs ?? null) : liveOn ? realUsage.week.resetMs : null
661
+ const sp = sessPct ?? 0
662
+ const wp = wkPct ?? 0
485
663
 
486
664
  const fireAt = currentConfig.fireThreshold ?? 90
487
665
  let st =
488
- liveOn && sessPct >= 100
666
+ liveOn && sp >= 100
489
667
  ? 'tired'
490
668
  : d.active
491
669
  ? 'working'
492
- : liveOn && sessPct >= fireAt
670
+ : liveOn && sp >= fireAt
493
671
  ? 'stressed'
494
672
  : d.sleeping
495
673
  ? 'sleeping'
@@ -542,32 +720,34 @@ function render(d) {
542
720
  ? `${fmtTokens(d.tokensPerMin)} tok/min`
543
721
  : `${fmtTokens(d.today.tokens)} tokens today`
544
722
 
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)
723
+ const was = prevPct[view]
724
+ if (liveOn && was != null && sessActive && sessPct != null && was - sessPct > 25) celebrate()
725
+ prevPct[view] = sessPct
726
+ el('session-pct').textContent = pctText(liveOn ? sessPct : 0)
727
+ setLevel(el('session-pct'), liveOn ? sp : 0)
549
728
  const mini = el('mini-pct')
550
- mini.textContent = liveOn ? `${Math.round(sessPct)}%` : '—'
551
- mini.classList.toggle('high', liveOn && sessPct >= 80)
729
+ mini.textContent = liveOn ? pctText(sessPct) : '—'
730
+ mini.classList.toggle('high', liveOn && sp >= 80)
552
731
  // the collapsed ring: how much of the session is already spent
553
732
  const rf = el('ring-fill')
554
- rf.style.strokeDashoffset = `${RING_LEN * (1 - Math.min(sessPct, 100) / 100)}`
555
- rf.classList.toggle('high', sessPct >= 80)
733
+ rf.style.strokeDashoffset = `${RING_LEN * (1 - Math.min(sp, 100) / 100)}`
734
+ rf.classList.toggle('high', sp >= 80)
556
735
  const rp = el('ring-pct')
557
736
  rp.textContent = mini.textContent
558
- rp.classList.toggle('high', liveOn && sessPct >= 80)
737
+ rp.classList.toggle('high', liveOn && sp >= 80)
559
738
  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)
739
+ sf.style.width = `${sp}%`
740
+ sf.classList.toggle('high', sp >= 80)
741
+ setLevel(sf, sp)
742
+ el('session-sub').textContent = isCodex
743
+ ? codexSub(cv.sess, d.session.tokens, cv.stale)
744
+ : sessActive
745
+ ? `resets in ${fmtResetIn(sessReset)} · ${fmtTokens(d.session.tokens)} tokens`
746
+ : 'no active session'
747
+
748
+ // where this pace is taking you — Claude's only: Codex has no trail to read
569
749
  const proj =
570
- sessActive && sessPct < 100 ? burn.project(sessPct, sessReset, d.session.tokens) : null
750
+ !isCodex && sessActive && sp < 100 ? burn.project(sp, sessReset, d.session.tokens) : null
571
751
  const pe = el('session-proj')
572
752
  pe.hidden = !proj
573
753
  pe.classList.toggle('tight', proj?.kind === 'eta')
@@ -576,22 +756,24 @@ function render(d) {
576
756
  proj.kind === 'eta' ? `~${fmtReset(proj.ms)} left at this pace` : 'resets before you run out'
577
757
  }
578
758
 
579
- el('week-pct').textContent = `${Math.round(wkPct)}%`
580
- setLevel(el('week-pct'), wkPct)
759
+ el('week-pct').textContent = pctText(liveOn ? wkPct : 0)
760
+ setLevel(el('week-pct'), wp)
581
761
  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
762
+ wf.style.width = `${wp}%`
763
+ wf.classList.toggle('high', wp >= 80)
764
+ setLevel(wf, wp)
765
+ el('week-sub').textContent = isCodex
766
+ ? codexSub(cv.week, d.week.tokens, cv.stale)
767
+ : wkReset != null
587
768
  ? `resets in ${fmtResetIn(wkReset)} · ${fmtTokens(d.week.tokens)} tokens`
588
769
  : `${fmtTokens(d.week.tokens)} tokens · last 7 days`
589
770
 
590
- renderScoped(liveOn ? realUsage.scoped || [] : [], d.byModel || [])
771
+ renderScoped(!isCodex && liveOn ? realUsage.scoped || [] : [], d.byModel || [])
591
772
  renderModels(d.byModel || [])
592
773
  renderProjects(d.byProject || [])
593
774
  renderHeat(d.days30 || [])
594
- el('month-total').textContent = `${fmtTokens(d.monthTokens)} tokens`
775
+ el('month-total').textContent = d.monthTokens == null ? '—' : `${fmtTokens(d.monthTokens)} tokens`
776
+ paintChip()
595
777
 
596
778
  currentRate = d.tokensPerMin || 0
597
779
  if (st === 'working') {
@@ -614,7 +796,9 @@ function fitSize() {
614
796
  requestAnimationFrame(() => {
615
797
  const collapsed = document.body.classList.contains('collapsed')
616
798
  const zoom = Number.parseFloat(document.body.style.zoom) || 1
617
- const w = (collapsed ? 168 : 276) * zoom
799
+ // The dual-service dock trades height for a little horizontal room.
800
+ const dual = document.body.classList.contains('dual')
801
+ const w = (collapsed ? (dual ? 240 : 168) : 304) * zoom
618
802
  // offsetHeight never reflects a CSS zoom applied to an ancestor (confirmed
619
803
  // empirically against this Electron build) — so measure the unzoomed content
620
804
  // height, then scale the whole thing (content + margin) ourselves
@@ -651,9 +835,16 @@ window.api.onError((msg) => {
651
835
  window.api.onConfig((cfg) => {
652
836
  currentConfig = cfg || {}
653
837
  document.body.classList.toggle('is-menubar', currentConfig.mode === 'menubar')
838
+ document.body.classList.toggle('codex-on', !!currentConfig.codex)
839
+ if (!currentConfig.codex) codexData = null
840
+ paint()
654
841
  applyZoom(currentConfig.zoom)
655
842
  fitSize()
656
843
  })
844
+ window.api.onCodex((c) => {
845
+ codexData = c || null
846
+ paint()
847
+ })
657
848
  window.api.onRealUsage((u) => {
658
849
  realUsage = u || null
659
850
  if (lastData) render(lastData)
@@ -669,6 +860,7 @@ window.api.onAuthState((s) => {
669
860
  endLogin()
670
861
  showProfile(null)
671
862
  }
863
+ paint() // losing Claude can leave Codex as the only service
672
864
  if (document.body.classList.contains('settings-open')) fitSize()
673
865
  })
674
866
 
@@ -689,12 +881,24 @@ function paintChip() {
689
881
  const active = (a?.accounts || []).find((x) => x.id === a?.active)
690
882
  const email = p?.email || (active?.connected ? active.label : null)
691
883
  // 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
884
+ // logo identifies the service, followed by the same plan badge and email.
693
885
  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() : '●'
886
+ setPlan(el('acc-sub'), p?.plan)
887
+ el('acc-ok').title = email || 'Connected'
696
888
  const chip = el('account-chip')
697
889
  const mini = el('mini-acct')
890
+ // the Codex view never borrows Claude's identity: its logs carry no email
891
+ if (viewProvider() === 'codex') {
892
+ const plan = codexData?.plan || null
893
+ el('ac-email').textContent = 'Codex'
894
+ chip.title = 'Codex on this computer'
895
+ setPlan(el('ac-plan'), plan)
896
+ chip.hidden = false
897
+ el('mini-acct-name').textContent = 'Codex'
898
+ setPlan(el('mini-acct-plan'), plan)
899
+ mini.hidden = false
900
+ return
901
+ }
698
902
  if (!email) {
699
903
  chip.hidden = true
700
904
  mini.hidden = true
@@ -832,6 +1036,8 @@ function toggleAccountMenu() {
832
1036
 
833
1037
  el('account-chip').addEventListener('click', (e) => {
834
1038
  e.stopPropagation()
1039
+ // Codex has no account switcher: its chip leads to its Settings block
1040
+ if (viewProvider() === 'codex') return openSettings()
835
1041
  toggleAccountMenu()
836
1042
  })
837
1043
  // clicking anywhere else — the pet, the gear, another app — puts it away
@@ -890,7 +1096,7 @@ window.api.onAuthResult((r) => {
890
1096
  }, 6000)
891
1097
  celebrate()
892
1098
  } else {
893
- // failed: put "Log in with browser" back, so a retry is one click away
1099
+ // failed: restore the connection card so a retry is one click away
894
1100
  document.body.classList.remove('awaiting')
895
1101
  const e = r?.error || ''
896
1102
  el('acc-msg').textContent = /429|rate_limit/i.test(e)
@@ -900,6 +1106,26 @@ window.api.onAuthResult((r) => {
900
1106
  fitSize()
901
1107
  })
902
1108
 
1109
+ // Keep the main view focused on limits; history is an explicit, remembered choice.
1110
+ const DETAILS_KEY = 'clauddy.details-open'
1111
+ function setDetailsOpen(open) {
1112
+ el('usage-details').hidden = !open
1113
+ el('details-toggle').setAttribute('aria-expanded', String(open))
1114
+ fitSize()
1115
+ }
1116
+ try {
1117
+ setDetailsOpen(localStorage.getItem(DETAILS_KEY) === 'true')
1118
+ } catch {
1119
+ setDetailsOpen(false)
1120
+ }
1121
+ el('details-toggle').addEventListener('click', () => {
1122
+ const open = el('usage-details').hidden
1123
+ setDetailsOpen(open)
1124
+ try {
1125
+ localStorage.setItem(DETAILS_KEY, String(open))
1126
+ } catch {}
1127
+ })
1128
+
903
1129
  // collapsible panel sections — the widget is a desktop pet, not a dashboard, so
904
1130
  // each breakdown can be folded away and the choice is remembered per machine
905
1131
  const SEC_KEY = 'clauddy.folded'
@@ -955,7 +1181,36 @@ for (const head of document.querySelectorAll('.sec-head')) {
955
1181
  for (const id of readFolded()) toggleSection(id, true)
956
1182
 
957
1183
  el('close').addEventListener('click', () => window.api.quit())
958
- el('usage').addEventListener('click', () => window.api.openUsage())
1184
+ el('usage').addEventListener('click', () => window.api.openUsage(viewProvider()))
1185
+
1186
+ // Settings → Codex: Connect looks for a local session and turns monitoring on if found
1187
+ function endCodexSetup() {
1188
+ document.body.classList.remove('codex-setup')
1189
+ el('codex-setup').hidden = true
1190
+ el('codex-hint').textContent = 'Track Codex alongside Claude, in this pet.'
1191
+ }
1192
+ el('codex-connect').addEventListener('click', () => {
1193
+ el('codex-hint').textContent = 'Looking…'
1194
+ window.api.codexDetect()
1195
+ })
1196
+ el('codex-retry').addEventListener('click', () => window.api.codexDetect())
1197
+ // Connect is the consent: found → monitoring starts, not found → say why
1198
+ window.api.onCodexDetected((r) => {
1199
+ if (r?.found) {
1200
+ endCodexSetup()
1201
+ window.api.codexEnable(true)
1202
+ return
1203
+ }
1204
+ document.body.classList.add('codex-setup')
1205
+ el('codex-setup').hidden = false
1206
+ el('codex-hint').textContent = 'No session — sign in to Codex'
1207
+ fitSize()
1208
+ })
1209
+ el('codex-setup-cancel').addEventListener('click', () => {
1210
+ endCodexSetup()
1211
+ fitSize()
1212
+ })
1213
+ el('codex-disconnect').addEventListener('click', () => window.api.codexEnable(false))
959
1214
 
960
1215
  // account login (browser flow)
961
1216
  el('acc-connect').addEventListener('click', () => window.api.authStart())
@@ -1105,6 +1360,7 @@ let closingTimer = null
1105
1360
  function closeSettings() {
1106
1361
  if (!document.body.classList.contains('settings-open')) return
1107
1362
  abandonLogin()
1363
+ endCodexSetup()
1108
1364
  document.body.classList.remove('settings-open')
1109
1365
  document.body.classList.add('settings-closing')
1110
1366
  clearTimeout(closingTimer)
@@ -1217,6 +1473,8 @@ if (typeof module === 'object' && module.exports) {
1217
1473
  showProfile,
1218
1474
  renderAccounts,
1219
1475
  renderAccountMenu,
1476
+ paint,
1477
+ pickProvider,
1220
1478
  burn,
1221
1479
  }
1222
1480
  }