clauddy 1.27.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,31 +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
806
  // the highest session % across connected services, and which ones are at or
784
- // past `from`; an expired Codex reading is null, so it never counts
807
+ // past `from`; an expired Codex or Cursor reading is null, so it never counts
785
808
  function sessionHeat(from) {
786
- const pcts = {
787
- claude: claudeOn() ? realUsage?.session?.pct : null,
788
- codex: codexOn() ? codexData?.session?.pct : null,
789
- }
790
- const known = Object.entries(pcts).filter(([, p]) => p != null)
809
+ const known = connected()
810
+ .map((p) => [p, sessionOf(p)?.pct])
811
+ .filter(([, pct]) => pct != null)
791
812
  return {
792
- pct: Math.max(0, ...known.map(([, p]) => p)),
793
- providers: known.filter(([, p]) => p >= from).map(([k]) => k),
813
+ pct: Math.max(0, ...known.map(([, pct]) => pct)),
814
+ providers: known.filter(([, pct]) => pct >= from).map(([p]) => p),
794
815
  }
795
816
  }
796
817
 
797
- // Codex's numbers in the shape the panel draws; its logs have no activity or tok/min
798
- function codexView(c) {
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) {
799
821
  const idleFor = c.lastSeen ? Date.now() - c.lastSeen : null
800
822
  return {
801
823
  d: {
802
824
  active: c.active,
803
- activity: null,
825
+ activity: p === 'cursor' ? c.activity : null,
804
826
  // no data is not the same as asleep
805
827
  sleeping: !c.active && idleFor != null && idleFor >= CODEX_SLEEP_MS,
806
828
  tokensPerMin: 0,
@@ -813,82 +835,83 @@ function codexView(c) {
813
835
  monthTokens: c.monthTokens ?? null,
814
836
  },
815
837
  sess: c.session,
816
- week: c.weekly,
838
+ week: p === 'cursor' ? c.api : c.weekly,
817
839
  stale: c.limitsAt != null && Date.now() - c.limitsAt > STALE_MS ? c.limitsAt : null,
818
840
  }
819
841
  }
820
842
 
821
- // a Codex meter caption: its reset, or how old the reading is when that's what matters
822
- function codexSub(w, tokens, stale) {
823
- const t = `${fmtTokens(tokens)} tokens`
824
- if (!w) return `limits not recorded yet · ${t}`
825
- 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)
826
850
  if (stale) {
827
- const at = w.resetMs != null ? `resets ${fmtResetClock(w.resetMs)} · ` : ''
828
- 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`)
829
853
  }
830
- const reset = w.resetMs != null ? `resets in ${fmtResetIn(w.resetMs)} · ` : ''
831
- return `${reset}${t}`
854
+ return join(w.resetMs != null ? `resets in ${fmtResetIn(w.resetMs)}` : '', t) || '—'
832
855
  }
833
856
 
834
857
  const pctText = (v) => (v == null ? '—' : `${Math.round(v)}%`)
835
858
  const warnAt = () => Math.min(...(currentConfig.alertThresholds || [80, 95]))
836
859
 
837
- // tabs + the collapsed pair: both services' session %, whichever is on screen
860
+ // tabs + the collapsed dock: every service's session %, whichever is on screen
838
861
  function paintServices(view) {
839
- const dual = bothOn()
840
- document.body.classList.toggle('dual', dual)
841
- document.body.classList.toggle('view-codex', view === 'codex')
842
- // collapsed with both: when the selected service's session resets
843
- const resetMs = view === 'codex' ? codexData?.session?.resetMs : realUsage?.session?.resetMs
844
- 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
845
870
  el('mini-reset').textContent = resetMs == null ? '' : `resets ${fmtResetClock(resetMs)}`
846
- el('service-panel').setAttribute('role', dual ? 'tabpanel' : 'region')
847
- if (dual) {
871
+ el('service-panel').setAttribute('role', many ? 'tabpanel' : 'region')
872
+ if (many) {
848
873
  el('service-panel').setAttribute('aria-labelledby', `tab-${view}`)
849
874
  el('service-panel').removeAttribute('aria-label')
850
875
  } else {
851
876
  el('service-panel').removeAttribute('aria-labelledby')
852
- el('service-panel').setAttribute('aria-label', `${view === 'codex' ? 'Codex' : 'Claude'} usage`)
877
+ el('service-panel').setAttribute('aria-label', `${NAMES[view]} usage`)
853
878
  return
854
879
  }
855
- const cl = realUsage
856
- const cx = codexData
857
- const rows = {
858
- claude: {
859
- pct: cl?.session?.pct ?? null,
860
- week: cl?.week?.pct ?? null,
861
- active: !!lastData?.active,
862
- },
863
- codex: {
864
- pct: cx?.session?.pct ?? null,
865
- week: cx?.weekly?.pct ?? null,
866
- active: !!cx?.active,
867
- stale: codexStale(cx),
868
- },
880
+ // what each service calls its two meters
881
+ const labels = {
882
+ claude: ['session', 'weekly'],
883
+ codex: ['session', 'weekly'],
884
+ cursor: ['month', 'API'],
869
885
  }
870
- for (const [p, r] of Object.entries(rows)) {
871
- const on = p === view
872
- const urgent = Math.max(r.pct ?? 0, r.week ?? 0) >= warnAt()
886
+ for (const p of PROVIDERS) {
873
887
  const tab = el(`tab-${p}`)
874
- tab.setAttribute('aria-selected', String(on))
875
- tab.tabIndex = on ? 0 : -1
876
- 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)
877
901
  tab.classList.toggle('urgent', urgent)
878
902
  tab.querySelector('.tab-alert').hidden = !urgent
879
- el(`tab-${p}-value`).textContent = pctText(r.pct)
880
- const name = p === 'claude' ? 'Claude' : 'Codex'
881
- tab.title = `${name} session ${pctText(r.pct)} · weekly ${pctText(r.week)}${r.active ? ' · working' : ''}${r.stale ? ' · not updated recently' : ''}`
882
- const line = document.querySelector(`.mini-source[data-provider="${p}"]`)
883
- 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))
884
907
  line.title = tab.title
885
908
  line.setAttribute('aria-label', `Show ${tab.title}`)
886
909
  line.classList.toggle('urgent', urgent)
887
910
  tab.setAttribute('aria-label', `Show ${tab.title}`)
888
- el(`mini-${p}-value`).textContent = pctText(r.pct)
911
+ el(`mini-${p}-value`).textContent = pctText(pct)
889
912
  const bar = el(`mini-${p}-bar`)
890
- bar.style.width = `${Math.min(r.pct ?? 0, 100)}%`
891
- 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)
892
915
  }
893
916
  }
894
917
 
@@ -899,7 +922,7 @@ for (const b of document.querySelectorAll('#harness-tabs button, .mini-source'))
899
922
  })
900
923
  }
901
924
  el('harness-tabs').addEventListener('keydown', (e) => {
902
- const providers = ['claude', 'codex']
925
+ const providers = connected()
903
926
  const current = providers.indexOf(e.target.dataset.provider)
904
927
  if (current < 0) return
905
928
  let next
@@ -919,7 +942,7 @@ let sizeLoaded = false
919
942
  let peekTimer = null
920
943
  let nativePetInside = null
921
944
  let noticeTimer = null
922
- let reminders = { claude: null, codex: null }
945
+ let reminders = { claude: null, codex: null, cursor: null }
923
946
  const activityTracker = Companion.createActivityTracker()
924
947
 
925
948
  function setPetPeek(on) {
@@ -994,37 +1017,44 @@ for (const button of document.querySelectorAll('#pet-glance button[data-provider
994
1017
  button.addEventListener('click', () => pickProvider(button.dataset.provider))
995
1018
  }
996
1019
  function paintCompanion(view) {
997
- for (const provider of ['claude', 'codex']) {
998
- const connected = provider === 'claude' ? claudeOn() : codexOn()
999
- const session = provider === 'claude' ? realUsage?.session : codexData?.session
1020
+ for (const provider of PROVIDERS) {
1021
+ const session = sessionOf(provider)
1000
1022
  const button = el(`glance-${provider}`)
1001
- button.hidden = !connected
1023
+ button.hidden = !isOn(provider)
1002
1024
  button.setAttribute('aria-pressed', String(provider === view))
1003
1025
  button.classList.toggle('urgent', session?.pct >= warnAt())
1004
1026
  el(`glance-${provider}-value`).textContent = pctText(session?.pct)
1005
- const stale = provider === 'codex' && codexStale(codexData)
1027
+ const stale = provider !== 'claude' && readingStale(dataOf(provider))
1006
1028
  button.title = stale
1007
1029
  ? 'Last recorded usage · waiting for a fresh reading'
1008
1030
  : `Show ${provider} usage`
1009
1031
  button.classList.toggle('stale-reading', !!stale)
1010
1032
  }
1011
- el('glance-empty').hidden = claudeOn() || codexOn()
1012
- const session = view === 'claude' ? realUsage?.session : codexData?.session
1033
+ el('glance-empty').hidden = connected().length > 0
1034
+ const session = sessionOf(view)
1013
1035
  const reminder = reminders[view]
1036
+ const ext = dataOf(view)
1014
1037
  const stale =
1015
- view === 'codex'
1016
- ? !codexData?.limitsAt || codexStale(codexData)
1017
- : !realUsageReadAt || Date.now() - realUsageReadAt > STALE_MS
1038
+ view === 'claude'
1039
+ ? !realUsageReadAt || Date.now() - realUsageReadAt > STALE_MS
1040
+ : !ext?.limitsAt || readingStale(ext)
1018
1041
  const available = session?.pct != null && session.resetMs > 0 && !stale
1019
- const name = view === 'codex' ? 'Codex' : 'Claude'
1042
+ const name = NAMES[view]
1020
1043
  for (const id of ['reminder-toggle', 'mini-reminder']) {
1021
1044
  const button = el(id)
1022
1045
  button.hidden = !reminder && (!available || session.pct < warnAt())
1023
1046
  button.setAttribute('aria-pressed', String(!!reminder))
1024
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
+ : ''
1025
1055
  button.title = reminder
1026
- ? `Cancel ${name} reminder for ${new Date(reminder.at).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`
1027
- : `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`
1028
1058
  }
1029
1059
  }
1030
1060
  function companionNotice(text, provider = null) {
@@ -1047,14 +1077,14 @@ for (const id of ['reminder-toggle', 'mini-reminder']) {
1047
1077
  })
1048
1078
  }
1049
1079
  window.api.onReminders((state) => {
1050
- reminders = state || { claude: null, codex: null }
1080
+ reminders = state || { claude: null, codex: null, cursor: null }
1051
1081
  if (state?.error) companionNotice(state.error)
1052
1082
  paintCompanion(viewProvider())
1053
1083
  fitSize()
1054
1084
  })
1055
1085
  window.api.onReminderDue((event) => {
1056
1086
  if (!event.isCurrent) return
1057
- const name = event.provider === 'codex' ? 'Codex' : 'Claude'
1087
+ const name = NAMES[event.provider] || 'Claude'
1058
1088
  companionNotice(
1059
1089
  event.confirmed ? `${name} · budget is back!` : `${name} · reset time reached`,
1060
1090
  event.provider,
@@ -1066,13 +1096,14 @@ function reactToProvider(provider, data) {
1066
1096
  const cue = activityTracker.observe(provider, data)
1067
1097
  if (!cue || document.body.classList.contains('settings-open')) return
1068
1098
  if (!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
1069
- 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]
1070
1101
  el('eyes').animate(
1071
1102
  [
1072
- { transform: 'translateX(0)' },
1073
- { transform: `translateX(${x}px)`, offset: 0.3 },
1074
- { transform: `translateX(${x}px)`, offset: 0.7 },
1075
- { 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)' },
1076
1107
  ],
1077
1108
  { duration: 1100, easing: 'ease-in-out' },
1078
1109
  )
@@ -1080,17 +1111,18 @@ function reactToProvider(provider, data) {
1080
1111
  }
1081
1112
 
1082
1113
  function paintActivity() {
1083
- const activity = Companion.currentActivity(
1084
- claudeOn() ? claudeActivityData : null,
1085
- codexOn() ? codexData : null,
1086
- )
1114
+ const activity = Companion.currentActivity({
1115
+ claude: claudeOn() ? claudeActivityData : null,
1116
+ codex: codexOn() ? codexData : null,
1117
+ cursor: cursorOn() ? cursorData : null,
1118
+ })
1087
1119
  paintLogos(activity.providers, activity.activity)
1088
1120
  return activity
1089
1121
  }
1090
1122
 
1091
1123
  // the logos under the pet name who it's about: the workers, or the service on fire
1092
1124
  function paintLogos(providers, caption) {
1093
- const names = providers.map((p) => (p === 'claude' ? 'Claude' : 'Codex')).join(' + ')
1125
+ const names = providers.map((p) => NAMES[p]).join(' + ')
1094
1126
  const label = names ? `${names} · ${caption}` : ''
1095
1127
  for (const id of ['pet-activity', 'mini-workers']) {
1096
1128
  const badge = el(id)
@@ -1123,22 +1155,27 @@ function render(d) {
1123
1155
 
1124
1156
  function paint() {
1125
1157
  setPlan(el('codex-plan'), codexData?.plan)
1158
+ setPlan(el('cursor-plan'), cursorData?.plan)
1159
+ paintConnections()
1126
1160
  const d0 = lastData
1127
1161
  if (!d0) return
1128
1162
  const view = viewProvider()
1129
1163
  paintServices(view)
1130
1164
  paintCompanion(view)
1131
- const isCodex = view === 'codex' && !!codexData
1132
- const cv = isCodex ? codexView(codexData) : null
1133
- const d = isCodex ? cv.d : d0
1134
- // % comes only from the connected account (or Codex's own logs) — no estimates
1135
- 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
1136
1173
  document.body.classList.toggle('live', liveOn)
1137
- const sessPct = isCodex ? (cv.sess?.pct ?? null) : liveOn ? realUsage.session.pct : 0
1138
- 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
1139
1176
  const sessActive = liveOn && sessReset != null
1140
- const wkPct = isCodex ? (cv.week?.pct ?? null) : liveOn ? realUsage.week.pct : 0
1141
- 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
1142
1179
  const sp = sessPct ?? 0
1143
1180
  const wp = wkPct ?? 0
1144
1181
 
@@ -1216,16 +1253,23 @@ function paint() {
1216
1253
  : 'idle'
1217
1254
  el('status-text').textContent = word
1218
1255
  el('mini-text').textContent = word
1219
- el('rate').textContent =
1220
- 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
1221
1265
  ? `${fmtTokens(d.tokensPerMin)} tok/min`
1222
1266
  : `${fmtTokens(d.today.tokens)} tokens today`
1223
1267
 
1224
1268
  const was = prevPct[view]
1225
1269
  if (liveOn && was != null && sessActive && sessPct != null && was - sessPct > 25) {
1226
1270
  celebrate()
1227
- // Codex's own reset is called out by listenCodex, on screen or not
1228
- 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 })
1229
1273
  }
1230
1274
  prevPct[view] = sessPct
1231
1275
  el('session-pct').textContent = pctText(liveOn ? sessPct : 0)
@@ -1244,15 +1288,24 @@ function paint() {
1244
1288
  sf.style.width = `${sp}%`
1245
1289
  sf.classList.toggle('high', sp >= 80)
1246
1290
  setLevel(sf, sp)
1247
- el('session-sub').textContent = isCodex
1248
- ? 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)
1249
1302
  : sessActive
1250
1303
  ? `resets in ${fmtResetIn(sessReset)} · ${fmtTokens(d.session.tokens)} tokens`
1251
1304
  : 'no active session'
1252
1305
 
1253
- // 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
1254
1307
  const proj =
1255
- !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
1256
1309
  const pe = el('session-proj')
1257
1310
  pe.hidden = !proj
1258
1311
  pe.classList.toggle('tight', proj?.kind === 'eta')
@@ -1267,13 +1320,15 @@ function paint() {
1267
1320
  wf.style.width = `${wp}%`
1268
1321
  wf.classList.toggle('high', wp >= 80)
1269
1322
  setLevel(wf, wp)
1270
- el('week-sub').textContent = isCodex
1271
- ? 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)
1272
1327
  : wkReset != null
1273
1328
  ? `resets in ${fmtResetIn(wkReset)} · ${fmtTokens(d.week.tokens)} tokens`
1274
1329
  : `${fmtTokens(d.week.tokens)} tokens · last 7 days`
1275
1330
 
1276
- renderScoped(!isCodex && liveOn ? realUsage.scoped || [] : [], d.byModel || [])
1331
+ renderScoped(!isExt && liveOn ? realUsage.scoped || [] : [], d.byModel || [])
1277
1332
  renderModels(d.byModel || [])
1278
1333
  renderProjects(d.byProject || [])
1279
1334
  renderHeat(d.days30 || [])
@@ -1293,7 +1348,7 @@ function paint() {
1293
1348
  stopEating()
1294
1349
  }
1295
1350
 
1296
- listen(before, st, { isCodex, liveOn, sp, sessReset, proj })
1351
+ listen(before, st, { isExt, liveOn, sp, sessReset, proj })
1297
1352
  fitSize()
1298
1353
  }
1299
1354
 
@@ -1304,10 +1359,12 @@ function fitSize() {
1304
1359
  requestAnimationFrame(() => {
1305
1360
  const collapsed = document.body.classList.contains('collapsed')
1306
1361
  const zoom = Number.parseFloat(document.body.style.zoom) || 1
1307
- // 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.
1308
1364
  const dual = document.body.classList.contains('dual')
1365
+ const trio = document.body.classList.contains('trio')
1309
1366
  const petOnly = document.body.classList.contains('pet-only')
1310
- const w = (petOnly ? 168 : collapsed ? (dual ? 240 : 192) : 304) * zoom
1367
+ const w = (petOnly ? (trio ? 212 : 168) : collapsed ? (dual ? 240 : 192) : 304) * zoom
1311
1368
  // offsetHeight never reflects a CSS zoom applied to an ancestor (confirmed
1312
1369
  // empirically against this Electron build) — so measure the unzoomed content
1313
1370
  // height, then scale the whole thing (content + margin) ourselves
@@ -1349,6 +1406,9 @@ window.api.onConfig((cfg) => {
1349
1406
  document.body.classList.toggle('codex-on', !!currentConfig.codex)
1350
1407
  if (!currentConfig.codex) codexData = null
1351
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')
1352
1412
  if (!sizeLoaded) {
1353
1413
  sizeLoaded = true
1354
1414
  let size = 'expanded'
@@ -1370,6 +1430,11 @@ window.api.onCodex((c) => {
1370
1430
  reactToProvider('codex', codexOn() ? c : null)
1371
1431
  paint()
1372
1432
  })
1433
+ window.api.onCursor((c) => {
1434
+ cursorData = c || null
1435
+ reactToProvider('cursor', cursorOn() ? c : null)
1436
+ paint()
1437
+ })
1373
1438
  window.api.onRealUsage((u) => {
1374
1439
  realUsage = u || null
1375
1440
  realUsageReadAt = u ? Date.now() : 0
@@ -1388,7 +1453,7 @@ window.api.onAuthState((s) => {
1388
1453
  endLogin()
1389
1454
  showProfile(null)
1390
1455
  }
1391
- paint() // losing Claude can leave Codex as the only service
1456
+ paint() // losing Claude can leave Codex or Cursor as the only service
1392
1457
  if (document.body.classList.contains('settings-open')) fitSize()
1393
1458
  })
1394
1459
 
@@ -1397,6 +1462,7 @@ let lastProfile = null
1397
1462
  function showProfile(p) {
1398
1463
  lastProfile = p
1399
1464
  paintChip()
1465
+ paintConnections()
1400
1466
  }
1401
1467
 
1402
1468
  // The chip is also the account switcher, so it must not vanish just because the
@@ -1415,14 +1481,16 @@ function paintChip() {
1415
1481
  el('acc-ok').title = email || 'Connected'
1416
1482
  const chip = el('account-chip')
1417
1483
  const mini = el('mini-acct')
1418
- // the Codex view never borrows Claude's identity: its logs carry no email
1419
- if (viewProvider() === 'codex') {
1420
- const plan = codexData?.plan || null
1421
- el('ac-email').textContent = 'Codex'
1422
- 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`
1423
1491
  setPlan(el('ac-plan'), plan)
1424
1492
  chip.hidden = false
1425
- el('mini-acct-name').textContent = 'Codex'
1493
+ el('mini-acct-name').textContent = name
1426
1494
  setPlan(el('mini-acct-plan'), plan)
1427
1495
  mini.hidden = false
1428
1496
  return
@@ -1564,8 +1632,8 @@ function toggleAccountMenu() {
1564
1632
 
1565
1633
  el('account-chip').addEventListener('click', (e) => {
1566
1634
  e.stopPropagation()
1567
- // Codex has no account switcher: its chip leads to its Settings block
1568
- 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())
1569
1637
  toggleAccountMenu()
1570
1638
  })
1571
1639
  // clicking anywhere else — the pet, the gear, another app — puts it away
@@ -1745,6 +1813,34 @@ el('codex-setup-cancel').addEventListener('click', () => {
1745
1813
  })
1746
1814
  el('codex-disconnect').addEventListener('click', () => window.api.codexEnable(false))
1747
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
+
1748
1844
  // account login (browser flow)
1749
1845
  el('acc-connect').addEventListener('click', () => window.api.authStart())
1750
1846
  // main opened the browser — the only thing left to do is paste the code back,
@@ -1752,7 +1848,7 @@ el('acc-connect').addEventListener('click', () => window.api.authStart())
1752
1848
  window.api.onAuthPending(() => {
1753
1849
  // the login can start from the account menu, with the panel closed: the code
1754
1850
  // field is where the flow continues, so bring it up
1755
- openSettings()
1851
+ openSettings('claude')
1756
1852
  document.body.classList.add('awaiting')
1757
1853
  el('acc-paste').classList.add('show')
1758
1854
  el('acc-code').classList.remove('filled')
@@ -1887,14 +1983,50 @@ function populateSettings() {
1887
1983
  reflectTalkOn()
1888
1984
  clearSaveDirty() // fields now match the saved config
1889
1985
  }
1890
- function openSettings() {
1986
+ // `provider` opens that service's connection card straight away
1987
+ function openSettings(provider = null) {
1891
1988
  hideBubble()
1892
1989
  setDisplaySize('expanded', false)
1893
1990
  populateSettings()
1991
+ if (typeof provider === 'string') openConnection(provider)
1894
1992
  document.body.classList.add('settings-open')
1895
1993
  fitSize()
1896
1994
  }
1897
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
+
1898
2030
  // Leaving settings plays the panel out to the right while home slides back in.
1899
2031
  // The panel is lifted out of the flow for those few frames (see .settings-closing
1900
2032
  // in the stylesheet), so the card can already be measured at its home height.
@@ -1903,6 +2035,9 @@ function closeSettings() {
1903
2035
  if (!document.body.classList.contains('settings-open')) return
1904
2036
  abandonLogin()
1905
2037
  endCodexSetup()
2038
+ endCursorSetup()
2039
+ openConn = null
2040
+ paintConnections()
1906
2041
  document.body.classList.remove('settings-open')
1907
2042
  document.body.classList.add('settings-closing')
1908
2043
  clearTimeout(closingTimer)
@@ -1915,8 +2050,9 @@ el('gear').addEventListener('click', () => {
1915
2050
  if (document.body.classList.contains('settings-open')) closeSettings()
1916
2051
  else openSettings()
1917
2052
  })
2053
+
1918
2054
  // the "connect" placeholder jumps straight to settings
1919
- el('limits-connect').addEventListener('click', openSettings)
2055
+ el('limits-connect').addEventListener('click', () => openSettings('claude'))
1920
2056
  // custom number steppers (▲ / ▼)
1921
2057
  for (const b of document.querySelectorAll('.num-btn')) {
1922
2058
  b.addEventListener('click', () => {