clauddy 1.26.0 → 1.28.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
@@ -148,6 +148,8 @@ function fmtReset(ms) {
148
148
  function fmtResetClock(ms) {
149
149
  if (!ms || ms <= 0) return null
150
150
  const at = new Date(Date.now() + ms)
151
+ // a week or more out (Cursor's month), a weekday would be ambiguous
152
+ if (ms >= 6 * 86400000) return at.toLocaleDateString([], { month: 'short', day: 'numeric' })
151
153
  const time = at.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
152
154
  return ms >= 86400000 ? `${at.toLocaleDateString([], { weekday: 'short' })} ${time}` : time
153
155
  }
@@ -603,33 +605,35 @@ function takeRecap(was) {
603
605
  return r
604
606
  }
605
607
 
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
608
+ // Codex and Cursor, followed whichever tab is on screen. Codex's logs only move
609
+ // while it runs, so a stale reading keeps the baseline: the next fresh one is
610
+ // compared against the last number actually seen.
611
+ const lastExtPct = { codex: null, cursor: null }
612
+ function listenExt(p) {
613
+ const c = dataOf(p)
614
+ if (!isOn(p) || !c) {
615
+ lastExtPct[p] = null
614
616
  return
615
617
  }
616
618
  const pct = c.session?.pct
617
- if (pct == null || codexStale(c)) return
618
- const was = lastCodexPct
619
- lastCodexPct = pct
619
+ if (pct == null || readingStale(c)) return
620
+ const was = lastExtPct[p]
621
+ lastExtPct[p] = pct
620
622
  if (was == null) return
621
623
  const fireAt = currentConfig.fireThreshold ?? 90
622
624
  const reset = c.session.resetMs
623
- if (was - pct > 25) say(Voice.codexResetLine(was), { headline: true })
625
+ const name = NAMES[p]
626
+ if (was - pct > 25)
627
+ say(Voice.codexResetLine(was, name, p === 'cursor' ? 'month' : 'window'), { headline: true })
624
628
  else if (was < 100 && pct >= 100)
625
- say(Voice.codexMaxedLine(fmtResetClock(reset)), { mood: 'sleepy' })
629
+ say(Voice.codexMaxedLine(fmtResetClock(reset), name), { mood: 'sleepy' })
626
630
  else if (was < fireAt && pct >= fireAt && pct < 100)
627
- say(Voice.codexFireLine(pct, reset, fmtReset), { headline: true, mood: 'fire' })
631
+ say(Voice.codexFireLine(pct, reset, fmtReset, name), { headline: true, mood: 'fire' })
628
632
  }
629
633
 
630
634
  // 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 }) {
635
+ // Claude view; Codex and Cursor have their own, above.
636
+ function listen(before, st, { isExt, liveOn, sp, sessReset, proj }) {
633
637
  // the saved config decides whether it talks at all, and it lands just after
634
638
  // the first usage tick: speaking before it would ignore a "talk: false"
635
639
  if (!heardConfig) return
@@ -643,8 +647,9 @@ function listen(before, st, { isCodex, liveOn, sp, sessReset, proj }) {
643
647
  if (say(line, { headline: true, mood })) return
644
648
  }
645
649
  }
646
- listenCodex()
647
- if (isCodex) return
650
+ listenExt('codex')
651
+ listenExt('cursor')
652
+ if (isExt) return
648
653
  const now = Date.now()
649
654
 
650
655
  if (liveOn) {
@@ -701,6 +706,8 @@ function sampleLine(kind) {
701
706
  }
702
707
  if (kind === 'codex')
703
708
  return Voice.codexFireLine(91, codexData?.session?.resetMs ?? reset, fmtReset)
709
+ if (kind === 'cursor')
710
+ return Voice.codexFireLine(91, cursorData?.session?.resetMs ?? reset, fmtReset, 'Cursor')
704
711
  if (kind === 'maxed') return Voice.maxedLine(fmtResetClock(reset))
705
712
  if (kind === 'welcome') return Voice.welcomeLine(3 * 3600000 + 12 * 60000, pct ?? null, fmtReset)
706
713
  if (kind === 'streak') return Voice.streakLine(95 * 60000, fmtReset)
@@ -714,7 +721,11 @@ function sampleLine(kind) {
714
721
  function sayDebug(kind) {
715
722
  const st = [...document.body.classList].find((c) => c.startsWith('state-'))?.slice(6)
716
723
  const mood =
717
- kind === 'fire' || kind === 'codex' ? 'fire' : kind === 'maxed' ? 'sleepy' : moodOf(st)
724
+ kind === 'fire' || kind === 'codex' || kind === 'cursor'
725
+ ? 'fire'
726
+ : kind === 'maxed'
727
+ ? 'sleepy'
728
+ : moodOf(st)
718
729
  return say(sampleLine(kind), { force: true, mood })
719
730
  }
720
731
 
@@ -742,14 +753,18 @@ el('mute').addEventListener('click', (e) => {
742
753
  })
743
754
 
744
755
  // ---- services ---------------------------------------------------------------
745
- // Claude and Codex are monitored side by side, but the panel shows one at a
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.
756
+ // Claude, Codex and Cursor are monitored side by side, but the panel shows one
757
+ // at a time: tabs own the chip, meters, breakdowns and Usage arrow. The expanded
758
+ // scene follows that view; compact pets follow activity across every service.
748
759
  const PROVIDER_KEY = 'clauddy.provider'
760
+ const PROVIDERS = ['claude', 'codex', 'cursor']
761
+ const NAMES = { claude: 'Claude', codex: 'Codex', cursor: 'Cursor' }
749
762
  let codexData = null // last Codex payload; null while Codex isn't enabled
763
+ let cursorData = null // last Cursor payload; null while Cursor isn't enabled
750
764
  let pickedProvider = (() => {
751
765
  try {
752
- return localStorage.getItem(PROVIDER_KEY) === 'codex' ? 'codex' : 'claude'
766
+ const p = localStorage.getItem(PROVIDER_KEY)
767
+ return PROVIDERS.includes(p) ? p : 'claude'
753
768
  } catch {
754
769
  return 'claude'
755
770
  }
@@ -757,12 +772,19 @@ let pickedProvider = (() => {
757
772
 
758
773
  const claudeOn = () => document.body.classList.contains('auth-on')
759
774
  const codexOn = () => !!currentConfig.codex
760
- const bothOn = () => claudeOn() && codexOn()
775
+ const cursorOn = () => !!currentConfig.cursor
776
+ const isOn = (p) => (p === 'claude' ? claudeOn() : p === 'codex' ? codexOn() : cursorOn())
777
+ const connected = () => PROVIDERS.filter(isOn)
778
+ // Codex and Cursor payloads; Claude's live in lastData + realUsage
779
+ const dataOf = (p) => (p === 'codex' ? codexData : p === 'cursor' ? cursorData : null)
780
+ // each service's two meters: the window that resets, and the one beside it
781
+ const sessionOf = (p) => (p === 'claude' ? realUsage?.session : dataOf(p)?.session)
782
+ const secondOf = (p) =>
783
+ p === 'claude' ? realUsage?.week : p === 'codex' ? codexData?.weekly : cursorData?.api
761
784
  // the service on screen: the pick, unless that one isn't there any more
762
785
  function viewProvider() {
763
- if (!codexOn()) return 'claude'
764
- if (!claudeOn()) return 'codex'
765
- return pickedProvider
786
+ const on = connected()
787
+ return on.includes(pickedProvider) ? pickedProvider : on[0] || 'claude'
766
788
  }
767
789
 
768
790
  function pickProvider(p) {
@@ -776,17 +798,31 @@ function pickProvider(p) {
776
798
 
777
799
  const STALE_MS = 15 * 60000
778
800
  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) =>
801
+ // Codex logs are only written while it runs, so a reading can be hours old;
802
+ // Cursor's comes from a fetch that can fail. Either way: say how old it is.
803
+ const readingStale = (c) =>
781
804
  !!c && (c.session?.expired || (c.limitsAt != null && Date.now() - c.limitsAt > STALE_MS))
782
805
 
783
- // Codex's numbers in the shape the panel draws; its logs have no activity or tok/min
784
- function codexView(c) {
806
+ // the highest session % across connected services, and which ones are at or
807
+ // past `from`; an expired Codex or Cursor reading is null, so it never counts
808
+ function sessionHeat(from) {
809
+ const known = connected()
810
+ .map((p) => [p, sessionOf(p)?.pct])
811
+ .filter(([, pct]) => pct != null)
812
+ return {
813
+ pct: Math.max(0, ...known.map(([, pct]) => pct)),
814
+ providers: known.filter(([, pct]) => pct >= from).map(([p]) => p),
815
+ }
816
+ }
817
+
818
+ // Codex's or Cursor's numbers in the shape the panel draws. Codex's logs carry
819
+ // tokens but no activity; Cursor's transcripts carry activity but no tokens.
820
+ function extView(p, c) {
785
821
  const idleFor = c.lastSeen ? Date.now() - c.lastSeen : null
786
822
  return {
787
823
  d: {
788
824
  active: c.active,
789
- activity: null,
825
+ activity: p === 'cursor' ? c.activity : null,
790
826
  // no data is not the same as asleep
791
827
  sleeping: !c.active && idleFor != null && idleFor >= CODEX_SLEEP_MS,
792
828
  tokensPerMin: 0,
@@ -799,82 +835,83 @@ function codexView(c) {
799
835
  monthTokens: c.monthTokens ?? null,
800
836
  },
801
837
  sess: c.session,
802
- week: c.weekly,
838
+ week: p === 'cursor' ? c.api : c.weekly,
803
839
  stale: c.limitsAt != null && Date.now() - c.limitsAt > STALE_MS ? c.limitsAt : null,
804
840
  }
805
841
  }
806
842
 
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}`
843
+ // a Codex or Cursor meter caption: its reset, or how old the reading is when
844
+ // that's what matters. Cursor has no token counts, so `tokens` is null there.
845
+ function extSub(w, tokens, stale, empty = 'limits not recorded yet') {
846
+ const t = tokens == null ? '' : `${fmtTokens(tokens)} tokens`
847
+ const join = (...parts) => parts.filter(Boolean).join(' · ')
848
+ if (!w) return join(empty, t)
849
+ if (w.pct == null) return join('waiting for a fresh reading', t)
812
850
  if (stale) {
813
- const at = w.resetMs != null ? `resets ${fmtResetClock(w.resetMs)} · ` : ''
814
- return `${at}read ${fmtReset(Date.now() - stale)} ago`
851
+ const at = w.resetMs != null ? `resets ${fmtResetClock(w.resetMs)}` : ''
852
+ return join(at, `read ${fmtReset(Date.now() - stale)} ago`)
815
853
  }
816
- const reset = w.resetMs != null ? `resets in ${fmtResetIn(w.resetMs)} · ` : ''
817
- return `${reset}${t}`
854
+ return join(w.resetMs != null ? `resets in ${fmtResetIn(w.resetMs)}` : '', t) || '—'
818
855
  }
819
856
 
820
857
  const pctText = (v) => (v == null ? '—' : `${Math.round(v)}%`)
821
858
  const warnAt = () => Math.min(...(currentConfig.alertThresholds || [80, 95]))
822
859
 
823
- // tabs + the collapsed pair: both services' session %, whichever is on screen
860
+ // tabs + the collapsed dock: every service's session %, whichever is on screen
824
861
  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
862
+ const on = connected()
863
+ const many = on.length > 1
864
+ document.body.classList.toggle('dual', many)
865
+ document.body.classList.toggle('trio', on.length > 2)
866
+ for (const p of ['codex', 'cursor']) document.body.classList.toggle(`view-${p}`, view === p)
867
+ // collapsed with several: when the selected service's window resets
868
+ const resetMs = sessionOf(view)?.resetMs
869
+ el('mini-reset').hidden = !many || resetMs == null
831
870
  el('mini-reset').textContent = resetMs == null ? '' : `resets ${fmtResetClock(resetMs)}`
832
- el('service-panel').setAttribute('role', dual ? 'tabpanel' : 'region')
833
- if (dual) {
871
+ el('service-panel').setAttribute('role', many ? 'tabpanel' : 'region')
872
+ if (many) {
834
873
  el('service-panel').setAttribute('aria-labelledby', `tab-${view}`)
835
874
  el('service-panel').removeAttribute('aria-label')
836
875
  } else {
837
876
  el('service-panel').removeAttribute('aria-labelledby')
838
- el('service-panel').setAttribute('aria-label', `${view === 'codex' ? 'Codex' : 'Claude'} usage`)
877
+ el('service-panel').setAttribute('aria-label', `${NAMES[view]} usage`)
839
878
  return
840
879
  }
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
- },
880
+ // what each service calls its two meters
881
+ const labels = {
882
+ claude: ['session', 'weekly'],
883
+ codex: ['session', 'weekly'],
884
+ cursor: ['month', 'API'],
855
885
  }
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()
886
+ for (const p of PROVIDERS) {
859
887
  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)
888
+ const line = document.querySelector(`.mini-source[data-provider="${p}"]`)
889
+ tab.hidden = !on.includes(p)
890
+ line.hidden = tab.hidden
891
+ if (tab.hidden) continue
892
+ const pct = sessionOf(p)?.pct ?? null
893
+ const second = secondOf(p)?.pct ?? null
894
+ const active = p === 'claude' ? !!lastData?.active : !!dataOf(p)?.active
895
+ const stale = p !== 'claude' && readingStale(dataOf(p))
896
+ const selected = p === view
897
+ const urgent = Math.max(pct ?? 0, second ?? 0) >= warnAt()
898
+ tab.setAttribute('aria-selected', String(selected))
899
+ tab.tabIndex = selected ? 0 : -1
900
+ tab.classList.toggle('active', active)
863
901
  tab.classList.toggle('urgent', urgent)
864
902
  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))
903
+ el(`tab-${p}-value`).textContent = pctText(pct)
904
+ const [first, next] = labels[p]
905
+ tab.title = `${NAMES[p]} ${first} ${pctText(pct)} · ${next} ${pctText(second)}${active ? ' · working' : ''}${stale ? ' · not updated recently' : ''}`
906
+ line.setAttribute('aria-pressed', String(selected))
870
907
  line.title = tab.title
871
908
  line.setAttribute('aria-label', `Show ${tab.title}`)
872
909
  line.classList.toggle('urgent', urgent)
873
910
  tab.setAttribute('aria-label', `Show ${tab.title}`)
874
- el(`mini-${p}-value`).textContent = pctText(r.pct)
911
+ el(`mini-${p}-value`).textContent = pctText(pct)
875
912
  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)
913
+ bar.style.width = `${Math.min(pct ?? 0, 100)}%`
914
+ bar.classList.toggle('hot', (pct ?? 0) >= 80)
878
915
  }
879
916
  }
880
917
 
@@ -885,7 +922,7 @@ for (const b of document.querySelectorAll('#harness-tabs button, .mini-source'))
885
922
  })
886
923
  }
887
924
  el('harness-tabs').addEventListener('keydown', (e) => {
888
- const providers = ['claude', 'codex']
925
+ const providers = connected()
889
926
  const current = providers.indexOf(e.target.dataset.provider)
890
927
  if (current < 0) return
891
928
  let next
@@ -905,7 +942,7 @@ let sizeLoaded = false
905
942
  let peekTimer = null
906
943
  let nativePetInside = null
907
944
  let noticeTimer = null
908
- let reminders = { claude: null, codex: null }
945
+ let reminders = { claude: null, codex: null, cursor: null }
909
946
  const activityTracker = Companion.createActivityTracker()
910
947
 
911
948
  function setPetPeek(on) {
@@ -980,37 +1017,44 @@ for (const button of document.querySelectorAll('#pet-glance button[data-provider
980
1017
  button.addEventListener('click', () => pickProvider(button.dataset.provider))
981
1018
  }
982
1019
  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
1020
+ for (const provider of PROVIDERS) {
1021
+ const session = sessionOf(provider)
986
1022
  const button = el(`glance-${provider}`)
987
- button.hidden = !connected
1023
+ button.hidden = !isOn(provider)
988
1024
  button.setAttribute('aria-pressed', String(provider === view))
989
1025
  button.classList.toggle('urgent', session?.pct >= warnAt())
990
1026
  el(`glance-${provider}-value`).textContent = pctText(session?.pct)
991
- const stale = provider === 'codex' && codexStale(codexData)
1027
+ const stale = provider !== 'claude' && readingStale(dataOf(provider))
992
1028
  button.title = stale
993
1029
  ? 'Last recorded usage · waiting for a fresh reading'
994
1030
  : `Show ${provider} usage`
995
1031
  button.classList.toggle('stale-reading', !!stale)
996
1032
  }
997
- el('glance-empty').hidden = claudeOn() || codexOn()
998
- const session = view === 'claude' ? realUsage?.session : codexData?.session
1033
+ el('glance-empty').hidden = connected().length > 0
1034
+ const session = sessionOf(view)
999
1035
  const reminder = reminders[view]
1036
+ const ext = dataOf(view)
1000
1037
  const stale =
1001
- view === 'codex'
1002
- ? !codexData?.limitsAt || codexStale(codexData)
1003
- : !realUsageReadAt || Date.now() - realUsageReadAt > STALE_MS
1038
+ view === 'claude'
1039
+ ? !realUsageReadAt || Date.now() - realUsageReadAt > STALE_MS
1040
+ : !ext?.limitsAt || readingStale(ext)
1004
1041
  const available = session?.pct != null && session.resetMs > 0 && !stale
1005
- const name = view === 'codex' ? 'Codex' : 'Claude'
1042
+ const name = NAMES[view]
1006
1043
  for (const id of ['reminder-toggle', 'mini-reminder']) {
1007
1044
  const button = el(id)
1008
1045
  button.hidden = !reminder && (!available || session.pct < warnAt())
1009
1046
  button.setAttribute('aria-pressed', String(!!reminder))
1010
1047
  button.textContent = reminder ? '✓ Reminder on' : 'Notify at reset'
1048
+ const at = reminder ? new Date(reminder.at) : null
1049
+ // a month-out reset needs its date, not just a time
1050
+ const when = at
1051
+ ? at.getTime() - Date.now() >= 86400000
1052
+ ? at.toLocaleDateString([], { month: 'short', day: 'numeric' })
1053
+ : at.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
1054
+ : ''
1011
1055
  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`
1056
+ ? `Cancel ${name} reminder for ${when}`
1057
+ : `Remind me when ${name}'s ${view === 'cursor' ? 'monthly' : 'session'} reset is due`
1014
1058
  }
1015
1059
  }
1016
1060
  function companionNotice(text, provider = null) {
@@ -1033,14 +1077,14 @@ for (const id of ['reminder-toggle', 'mini-reminder']) {
1033
1077
  })
1034
1078
  }
1035
1079
  window.api.onReminders((state) => {
1036
- reminders = state || { claude: null, codex: null }
1080
+ reminders = state || { claude: null, codex: null, cursor: null }
1037
1081
  if (state?.error) companionNotice(state.error)
1038
1082
  paintCompanion(viewProvider())
1039
1083
  fitSize()
1040
1084
  })
1041
1085
  window.api.onReminderDue((event) => {
1042
1086
  if (!event.isCurrent) return
1043
- const name = event.provider === 'codex' ? 'Codex' : 'Claude'
1087
+ const name = NAMES[event.provider] || 'Claude'
1044
1088
  companionNotice(
1045
1089
  event.confirmed ? `${name} · budget is back!` : `${name} · reset time reached`,
1046
1090
  event.provider,
@@ -1052,13 +1096,14 @@ function reactToProvider(provider, data) {
1052
1096
  const cue = activityTracker.observe(provider, data)
1053
1097
  if (!cue || document.body.classList.contains('settings-open')) return
1054
1098
  if (!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
1055
- const x = provider === 'codex' ? 3 : -3
1099
+ // in tab order: Claude to the left, Codex to the right, Cursor down-right
1100
+ const [x, y] = { claude: [-3, 0], codex: [3, 0], cursor: [3, 1] }[provider] || [0, 0]
1056
1101
  el('eyes').animate(
1057
1102
  [
1058
- { transform: 'translateX(0)' },
1059
- { transform: `translateX(${x}px)`, offset: 0.3 },
1060
- { transform: `translateX(${x}px)`, offset: 0.7 },
1061
- { transform: 'translateX(0)' },
1103
+ { transform: 'translate(0, 0)' },
1104
+ { transform: `translate(${x}px, ${y}px)`, offset: 0.3 },
1105
+ { transform: `translate(${x}px, ${y}px)`, offset: 0.7 },
1106
+ { transform: 'translate(0, 0)' },
1062
1107
  ],
1063
1108
  { duration: 1100, easing: 'ease-in-out' },
1064
1109
  )
@@ -1066,23 +1111,28 @@ function reactToProvider(provider, data) {
1066
1111
  }
1067
1112
 
1068
1113
  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}` : ''
1114
+ const activity = Companion.currentActivity({
1115
+ claude: claudeOn() ? claudeActivityData : null,
1116
+ codex: codexOn() ? codexData : null,
1117
+ cursor: cursorOn() ? cursorData : null,
1118
+ })
1119
+ paintLogos(activity.providers, activity.activity)
1120
+ return activity
1121
+ }
1122
+
1123
+ // the logos under the pet name who it's about: the workers, or the service on fire
1124
+ function paintLogos(providers, caption) {
1125
+ const names = providers.map((p) => NAMES[p]).join(' + ')
1126
+ const label = names ? `${names} · ${caption}` : ''
1075
1127
  for (const id of ['pet-activity', 'mini-workers']) {
1076
1128
  const badge = el(id)
1077
1129
  badge.hidden = !names
1078
1130
  badge.setAttribute('aria-label', label)
1079
1131
  for (const logo of badge.querySelectorAll('[data-provider]')) {
1080
- logo.hidden = !activity.providers.includes(logo.dataset.provider)
1132
+ logo.hidden = !providers.includes(logo.dataset.provider)
1081
1133
  }
1082
1134
  }
1083
- el('mini-dot').hidden =
1084
- activity.providers.length > 0 && document.body.classList.contains('collapsed')
1085
- return activity
1135
+ el('mini-dot').hidden = providers.length > 0 && document.body.classList.contains('collapsed')
1086
1136
  }
1087
1137
 
1088
1138
  // main render
@@ -1105,22 +1155,27 @@ function render(d) {
1105
1155
 
1106
1156
  function paint() {
1107
1157
  setPlan(el('codex-plan'), codexData?.plan)
1158
+ setPlan(el('cursor-plan'), cursorData?.plan)
1159
+ paintConnections()
1108
1160
  const d0 = lastData
1109
1161
  if (!d0) return
1110
1162
  const view = viewProvider()
1111
1163
  paintServices(view)
1112
1164
  paintCompanion(view)
1113
- const isCodex = view === 'codex' && !!codexData
1114
- const cv = isCodex ? codexView(codexData) : null
1115
- const d = isCodex ? cv.d : d0
1116
- // % comes only from the connected account (or Codex's own logs) — no estimates
1117
- const liveOn = isCodex || !!realUsage
1165
+ // Codex or Cursor on screen: their own numbers, never Claude's
1166
+ const ext = view !== 'claude' ? dataOf(view) : null
1167
+ const isExt = !!ext
1168
+ const isCursor = view === 'cursor' && isExt
1169
+ const cv = isExt ? extView(view, ext) : null
1170
+ const d = isExt ? cv.d : d0
1171
+ // % comes only from the connected account (or the service's own data) — no estimates
1172
+ const liveOn = isExt || !!realUsage
1118
1173
  document.body.classList.toggle('live', liveOn)
1119
- const sessPct = isCodex ? (cv.sess?.pct ?? null) : liveOn ? realUsage.session.pct : 0
1120
- const sessReset = isCodex ? (cv.sess?.resetMs ?? null) : liveOn ? realUsage.session.resetMs : null
1174
+ const sessPct = isExt ? (cv.sess?.pct ?? null) : liveOn ? realUsage.session.pct : 0
1175
+ const sessReset = isExt ? (cv.sess?.resetMs ?? null) : liveOn ? realUsage.session.resetMs : null
1121
1176
  const sessActive = liveOn && sessReset != null
1122
- const wkPct = isCodex ? (cv.week?.pct ?? null) : liveOn ? realUsage.week.pct : 0
1123
- const wkReset = isCodex ? (cv.week?.resetMs ?? null) : liveOn ? realUsage.week.resetMs : null
1177
+ const wkPct = isExt ? (cv.week?.pct ?? null) : liveOn ? realUsage.week.pct : 0
1178
+ const wkReset = isExt ? (cv.week?.resetMs ?? null) : liveOn ? realUsage.week.resetMs : null
1124
1179
  const sp = sessPct ?? 0
1125
1180
  const wp = wkPct ?? 0
1126
1181
 
@@ -1140,7 +1195,20 @@ function paint() {
1140
1195
  const activity = paintActivity()
1141
1196
  const collapsedNow = document.body.classList.contains('collapsed')
1142
1197
  if (collapsedNow) {
1143
- st = activity.providers.length ? 'working' : activity.sleeping ? 'sleeping' : 'idle'
1198
+ // work wins; at rest, the hottest connected session still sets the mood,
1199
+ // and its logo takes the status dot's place
1200
+ const heat = sessionHeat(fireAt)
1201
+ st = activity.providers.length
1202
+ ? 'working'
1203
+ : heat.pct >= 100
1204
+ ? 'tired'
1205
+ : heat.pct >= fireAt
1206
+ ? 'stressed'
1207
+ : activity.sleeping
1208
+ ? 'sleeping'
1209
+ : 'idle'
1210
+ if (st === 'tired') paintLogos(sessionHeat(100).providers, 'maxed out')
1211
+ else if (st === 'stressed') paintLogos(heat.providers, 'on fire')
1144
1212
  curActivity = activity.providers.length ? activity.activity : null
1145
1213
  }
1146
1214
  // dev override from `./pet <state>` (base states or an activity name)
@@ -1185,16 +1253,23 @@ function paint() {
1185
1253
  : 'idle'
1186
1254
  el('status-text').textContent = word
1187
1255
  el('mini-text').textContent = word
1188
- el('rate').textContent =
1189
- d.active && d.tokensPerMin > 0
1256
+ // Cursor's transcripts carry no tokens: when it last ran is what there is to say
1257
+ const ranAgo = isCursor && ext.lastSeen ? Date.now() - ext.lastSeen : null
1258
+ el('rate').textContent = isCursor
1259
+ ? ranAgo == null
1260
+ ? 'no agent runs yet'
1261
+ : ranAgo < 60000
1262
+ ? 'agent ran just now'
1263
+ : `agent ran ${fmtReset(ranAgo)} ago`
1264
+ : d.active && d.tokensPerMin > 0
1190
1265
  ? `${fmtTokens(d.tokensPerMin)} tok/min`
1191
1266
  : `${fmtTokens(d.today.tokens)} tokens today`
1192
1267
 
1193
1268
  const was = prevPct[view]
1194
1269
  if (liveOn && was != null && sessActive && sessPct != null && was - sessPct > 25) {
1195
1270
  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 })
1271
+ // Codex's and Cursor's own resets are called out by listenExt, on screen or not
1272
+ if (!isExt) say(Voice.recapLine(takeRecap(was), fmtTokens, fmtReset), { headline: true })
1198
1273
  }
1199
1274
  prevPct[view] = sessPct
1200
1275
  el('session-pct').textContent = pctText(liveOn ? sessPct : 0)
@@ -1213,15 +1288,24 @@ function paint() {
1213
1288
  sf.style.width = `${sp}%`
1214
1289
  sf.classList.toggle('high', sp >= 80)
1215
1290
  setLevel(sf, sp)
1216
- el('session-sub').textContent = isCodex
1217
- ? codexSub(cv.sess, d.session.tokens, cv.stale)
1291
+ el('session-label').textContent = isCursor ? 'this month · included' : 'current session'
1292
+ el('week-label').textContent = isCursor ? 'API models · this month' : 'weekly · all models'
1293
+ el('session-sub').textContent = isExt
1294
+ ? isCursor
1295
+ ? extSub(
1296
+ cv.sess,
1297
+ null,
1298
+ cv.stale,
1299
+ ext.signedOut ? 'sign in to the Cursor app' : 'no reading yet',
1300
+ )
1301
+ : extSub(cv.sess, d.session.tokens, cv.stale)
1218
1302
  : sessActive
1219
1303
  ? `resets in ${fmtResetIn(sessReset)} · ${fmtTokens(d.session.tokens)} tokens`
1220
1304
  : 'no active session'
1221
1305
 
1222
- // where this pace is taking you — Claude's only: Codex has no trail to read
1306
+ // where this pace is taking you — Claude's only: the others have no trail to read
1223
1307
  const proj =
1224
- !isCodex && sessActive && sp < 100 ? burn.project(sp, sessReset, d.session.tokens) : null
1308
+ !isExt && sessActive && sp < 100 ? burn.project(sp, sessReset, d.session.tokens) : null
1225
1309
  const pe = el('session-proj')
1226
1310
  pe.hidden = !proj
1227
1311
  pe.classList.toggle('tight', proj?.kind === 'eta')
@@ -1236,13 +1320,15 @@ function paint() {
1236
1320
  wf.style.width = `${wp}%`
1237
1321
  wf.classList.toggle('high', wp >= 80)
1238
1322
  setLevel(wf, wp)
1239
- el('week-sub').textContent = isCodex
1240
- ? codexSub(cv.week, d.week.tokens, cv.stale)
1323
+ el('week-sub').textContent = isExt
1324
+ ? isCursor
1325
+ ? extSub(cv.week, null, cv.stale, '—')
1326
+ : extSub(cv.week, d.week.tokens, cv.stale)
1241
1327
  : wkReset != null
1242
1328
  ? `resets in ${fmtResetIn(wkReset)} · ${fmtTokens(d.week.tokens)} tokens`
1243
1329
  : `${fmtTokens(d.week.tokens)} tokens · last 7 days`
1244
1330
 
1245
- renderScoped(!isCodex && liveOn ? realUsage.scoped || [] : [], d.byModel || [])
1331
+ renderScoped(!isExt && liveOn ? realUsage.scoped || [] : [], d.byModel || [])
1246
1332
  renderModels(d.byModel || [])
1247
1333
  renderProjects(d.byProject || [])
1248
1334
  renderHeat(d.days30 || [])
@@ -1262,7 +1348,7 @@ function paint() {
1262
1348
  stopEating()
1263
1349
  }
1264
1350
 
1265
- listen(before, st, { isCodex, liveOn, sp, sessReset, proj })
1351
+ listen(before, st, { isExt, liveOn, sp, sessReset, proj })
1266
1352
  fitSize()
1267
1353
  }
1268
1354
 
@@ -1273,10 +1359,12 @@ function fitSize() {
1273
1359
  requestAnimationFrame(() => {
1274
1360
  const collapsed = document.body.classList.contains('collapsed')
1275
1361
  const zoom = Number.parseFloat(document.body.style.zoom) || 1
1276
- // The dual-service dock trades height for a little horizontal room.
1362
+ // The multi-service dock trades height for a little horizontal room, and
1363
+ // three readings widen the pet-only glance.
1277
1364
  const dual = document.body.classList.contains('dual')
1365
+ const trio = document.body.classList.contains('trio')
1278
1366
  const petOnly = document.body.classList.contains('pet-only')
1279
- const w = (petOnly ? 168 : collapsed ? (dual ? 240 : 192) : 304) * zoom
1367
+ const w = (petOnly ? (trio ? 212 : 168) : collapsed ? (dual ? 240 : 192) : 304) * zoom
1280
1368
  // offsetHeight never reflects a CSS zoom applied to an ancestor (confirmed
1281
1369
  // empirically against this Electron build) — so measure the unzoomed content
1282
1370
  // height, then scale the whole thing (content + margin) ourselves
@@ -1318,6 +1406,9 @@ window.api.onConfig((cfg) => {
1318
1406
  document.body.classList.toggle('codex-on', !!currentConfig.codex)
1319
1407
  if (!currentConfig.codex) codexData = null
1320
1408
  if (!currentConfig.codex) activityTracker.forget('codex')
1409
+ document.body.classList.toggle('cursor-on', !!currentConfig.cursor)
1410
+ if (!currentConfig.cursor) cursorData = null
1411
+ if (!currentConfig.cursor) activityTracker.forget('cursor')
1321
1412
  if (!sizeLoaded) {
1322
1413
  sizeLoaded = true
1323
1414
  let size = 'expanded'
@@ -1339,6 +1430,11 @@ window.api.onCodex((c) => {
1339
1430
  reactToProvider('codex', codexOn() ? c : null)
1340
1431
  paint()
1341
1432
  })
1433
+ window.api.onCursor((c) => {
1434
+ cursorData = c || null
1435
+ reactToProvider('cursor', cursorOn() ? c : null)
1436
+ paint()
1437
+ })
1342
1438
  window.api.onRealUsage((u) => {
1343
1439
  realUsage = u || null
1344
1440
  realUsageReadAt = u ? Date.now() : 0
@@ -1357,7 +1453,7 @@ window.api.onAuthState((s) => {
1357
1453
  endLogin()
1358
1454
  showProfile(null)
1359
1455
  }
1360
- paint() // losing Claude can leave Codex as the only service
1456
+ paint() // losing Claude can leave Codex or Cursor as the only service
1361
1457
  if (document.body.classList.contains('settings-open')) fitSize()
1362
1458
  })
1363
1459
 
@@ -1366,6 +1462,7 @@ let lastProfile = null
1366
1462
  function showProfile(p) {
1367
1463
  lastProfile = p
1368
1464
  paintChip()
1465
+ paintConnections()
1369
1466
  }
1370
1467
 
1371
1468
  // The chip is also the account switcher, so it must not vanish just because the
@@ -1384,14 +1481,16 @@ function paintChip() {
1384
1481
  el('acc-ok').title = email || 'Connected'
1385
1482
  const chip = el('account-chip')
1386
1483
  const mini = el('mini-acct')
1387
- // the Codex view never borrows Claude's identity: its logs carry no email
1388
- if (viewProvider() === 'codex') {
1389
- const plan = codexData?.plan || null
1390
- el('ac-email').textContent = 'Codex'
1391
- chip.title = 'Codex on this computer'
1484
+ // the Codex and Cursor views never borrow Claude's identity
1485
+ const view = viewProvider()
1486
+ if (view !== 'claude') {
1487
+ const plan = dataOf(view)?.plan || null
1488
+ const name = NAMES[view]
1489
+ el('ac-email').textContent = name
1490
+ chip.title = `${name} on this computer`
1392
1491
  setPlan(el('ac-plan'), plan)
1393
1492
  chip.hidden = false
1394
- el('mini-acct-name').textContent = 'Codex'
1493
+ el('mini-acct-name').textContent = name
1395
1494
  setPlan(el('mini-acct-plan'), plan)
1396
1495
  mini.hidden = false
1397
1496
  return
@@ -1533,8 +1632,8 @@ function toggleAccountMenu() {
1533
1632
 
1534
1633
  el('account-chip').addEventListener('click', (e) => {
1535
1634
  e.stopPropagation()
1536
- // Codex has no account switcher: its chip leads to its Settings block
1537
- if (viewProvider() === 'codex') return openSettings()
1635
+ // Codex and Cursor have no account switcher: their chip leads to their card
1636
+ if (viewProvider() !== 'claude') return openSettings(viewProvider())
1538
1637
  toggleAccountMenu()
1539
1638
  })
1540
1639
  // clicking anywhere else — the pet, the gear, another app — puts it away
@@ -1714,6 +1813,34 @@ el('codex-setup-cancel').addEventListener('click', () => {
1714
1813
  })
1715
1814
  el('codex-disconnect').addEventListener('click', () => window.api.codexEnable(false))
1716
1815
 
1816
+ // Settings → Cursor: the same, but what it looks for is the Cursor app's login
1817
+ function endCursorSetup() {
1818
+ document.body.classList.remove('cursor-setup')
1819
+ el('cursor-setup').hidden = true
1820
+ el('cursor-hint').textContent = 'Track Cursor alongside Claude, in this pet.'
1821
+ }
1822
+ el('cursor-connect').addEventListener('click', () => {
1823
+ el('cursor-hint').textContent = 'Looking…'
1824
+ window.api.cursorDetect()
1825
+ })
1826
+ el('cursor-retry').addEventListener('click', () => window.api.cursorDetect())
1827
+ window.api.onCursorDetected((r) => {
1828
+ if (r?.found) {
1829
+ endCursorSetup()
1830
+ window.api.cursorEnable(true)
1831
+ return
1832
+ }
1833
+ document.body.classList.add('cursor-setup')
1834
+ el('cursor-setup').hidden = false
1835
+ el('cursor-hint').textContent = 'Not signed in — open the Cursor app and log in'
1836
+ fitSize()
1837
+ })
1838
+ el('cursor-setup-cancel').addEventListener('click', () => {
1839
+ endCursorSetup()
1840
+ fitSize()
1841
+ })
1842
+ el('cursor-disconnect').addEventListener('click', () => window.api.cursorEnable(false))
1843
+
1717
1844
  // account login (browser flow)
1718
1845
  el('acc-connect').addEventListener('click', () => window.api.authStart())
1719
1846
  // main opened the browser — the only thing left to do is paste the code back,
@@ -1721,7 +1848,7 @@ el('acc-connect').addEventListener('click', () => window.api.authStart())
1721
1848
  window.api.onAuthPending(() => {
1722
1849
  // the login can start from the account menu, with the panel closed: the code
1723
1850
  // field is where the flow continues, so bring it up
1724
- openSettings()
1851
+ openSettings('claude')
1725
1852
  document.body.classList.add('awaiting')
1726
1853
  el('acc-paste').classList.add('show')
1727
1854
  el('acc-code').classList.remove('filled')
@@ -1856,14 +1983,50 @@ function populateSettings() {
1856
1983
  reflectTalkOn()
1857
1984
  clearSaveDirty() // fields now match the saved config
1858
1985
  }
1859
- function openSettings() {
1986
+ // `provider` opens that service's connection card straight away
1987
+ function openSettings(provider = null) {
1860
1988
  hideBubble()
1861
1989
  setDisplaySize('expanded', false)
1862
1990
  populateSettings()
1991
+ if (typeof provider === 'string') openConnection(provider)
1863
1992
  document.body.classList.add('settings-open')
1864
1993
  fitSize()
1865
1994
  }
1866
1995
 
1996
+ // Settings → Connections: a tile per service says whether it's on (a check and
1997
+ // its plan); a click opens its card underneath, one at a time
1998
+ let openConn = null
1999
+ function openConnection(provider) {
2000
+ openConn = provider
2001
+ paintConnections()
2002
+ fitSize()
2003
+ }
2004
+ function paintConnections() {
2005
+ const plans = { claude: lastProfile?.plan, codex: codexData?.plan, cursor: cursorData?.plan }
2006
+ for (const p of PROVIDERS) {
2007
+ const tile = el(`conn-${p}`)
2008
+ const on = isOn(p)
2009
+ const plan = on ? plans[p] || null : null
2010
+ const open = openConn === p
2011
+ tile.setAttribute('aria-expanded', String(open))
2012
+ tile.classList.toggle('on', on)
2013
+ tile.querySelector('.conn-check').hidden = !on
2014
+ const state = el(`conn-${p}-state`)
2015
+ state.textContent = on ? plan || 'Connected' : 'Connect'
2016
+ state.classList.toggle('plan-badge', !!plan)
2017
+ tile.setAttribute(
2018
+ 'aria-label',
2019
+ `${NAMES[p]} · ${on ? `connected${plan ? `, ${plan}` : ''}` : 'not connected'}`,
2020
+ )
2021
+ el(p === 'claude' ? 'account' : `${p}-settings`).classList.toggle('open', open)
2022
+ }
2023
+ }
2024
+ for (const tile of document.querySelectorAll('.conn-tile')) {
2025
+ tile.addEventListener('click', () =>
2026
+ openConnection(openConn === tile.dataset.conn ? null : tile.dataset.conn),
2027
+ )
2028
+ }
2029
+
1867
2030
  // Leaving settings plays the panel out to the right while home slides back in.
1868
2031
  // The panel is lifted out of the flow for those few frames (see .settings-closing
1869
2032
  // in the stylesheet), so the card can already be measured at its home height.
@@ -1872,6 +2035,9 @@ function closeSettings() {
1872
2035
  if (!document.body.classList.contains('settings-open')) return
1873
2036
  abandonLogin()
1874
2037
  endCodexSetup()
2038
+ endCursorSetup()
2039
+ openConn = null
2040
+ paintConnections()
1875
2041
  document.body.classList.remove('settings-open')
1876
2042
  document.body.classList.add('settings-closing')
1877
2043
  clearTimeout(closingTimer)
@@ -1884,8 +2050,9 @@ el('gear').addEventListener('click', () => {
1884
2050
  if (document.body.classList.contains('settings-open')) closeSettings()
1885
2051
  else openSettings()
1886
2052
  })
2053
+
1887
2054
  // the "connect" placeholder jumps straight to settings
1888
- el('limits-connect').addEventListener('click', openSettings)
2055
+ el('limits-connect').addEventListener('click', () => openSettings('claude'))
1889
2056
  // custom number steppers (▲ / ▼)
1890
2057
  for (const b of document.querySelectorAll('.num-btn')) {
1891
2058
  b.addEventListener('click', () => {