clauddy 1.25.0 → 1.27.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/README.md CHANGED
@@ -215,6 +215,7 @@ update-desktop-database ~/.local/share/applications 2>/dev/null
215
215
 
216
216
  - **Drag** the widget anywhere on screen
217
217
  - **–** minimizes to just the pet, ringed by the live session % (the number sits inside the ring) — or, with Claude and Codex both connected, one reading per service; the **⤢** button or a double-click on the pet expands it back
218
+ - **Pet icon** in the compact header switches to **Just the pet** (floating mode). Drag the pet itself to move it. Hovering over the pet (or focusing it) reveals the usage glance; the small expand button in it returns to compact. Keyboard users can focus the pet and press Enter. The chosen size is remembered. In compact and pet-only modes, the pet follows whoever is working, with provider logos identifying Claude, Codex, or both. Usage selection stays manual. When neither is active, the hottest session still sets the mood: the pet catches fire (or maxes out) with that provider's logo beside it, and otherwise rests.
218
219
  - **⚙** opens settings (log in, toggle alerts, set thresholds, pick the display mode)
219
220
  - **↗** opens the official Usage page of the service on screen
220
221
  - **×** quits
@@ -228,6 +229,10 @@ Under **⚙ Settings → Preferences** you can pick where Clauddy lives:
228
229
 
229
230
  Switching is instant — no restart. (On Windows/Linux the icon lives in the system tray; the live % shows in its tooltip.)
230
231
 
232
+ ### Provider reactions
233
+
234
+ When a provider's activity changes, the pet briefly glances toward it, with no caption. Both can be working at once. A glance never changes your selected provider, and glances share a 30-second cooldown. Inactivity is treated as a pause, not a completed task.
235
+
231
236
  ## Alerts
232
237
 
233
238
  Optional **macOS notifications**, toggled (with their thresholds) in **⚙ Settings**:
@@ -247,6 +252,12 @@ Percentages come from your account when you're logged in, and fall back to the l
247
252
 
248
253
  The first alert asks macOS for permission; after that the app shows up in **System Settings > Notifications** like any other.
249
254
 
255
+ ### Remind me at the session reset
256
+
257
+ When the selected session crosses your first alert threshold, **Notify at reset** appears beneath the session details and in the compact monitor. Click to arm it; click **Reminder on** to cancel. This is a one-shot request, independent of the automatic threshold-alert toggle.
258
+
259
+ Reminders survive restarts, belong to the Claude account or Codex connection that created them, and are canceled when you disconnect that source. Clauddy must be running to notify; an overdue reminder is delivered on reopening or waking up. A fresh, lower reading confirms that session budget returned and the pet celebrates. If usage hasn't refreshed — common with idle Codex logs — the reminder only says the scheduled reset time arrived and asks you to check usage.
260
+
250
261
  ## Configure (`config.json`)
251
262
 
252
263
  Settings saved from the UI live in `~/.claude-usage-monitor/config.json`, so you can tweak them without rebuilding:
package/main.js CHANGED
@@ -19,6 +19,7 @@ const { getUsage } = usage
19
19
  const auth = require('./auth')
20
20
  const accounts = require('./accounts')
21
21
  const codex = require('./codex')
22
+ const { createReminders } = require('./reminders')
22
23
 
23
24
  const REPO = 'renatoaug/claude-usage-monitor'
24
25
  const USAGE_URLS = {
@@ -50,6 +51,7 @@ const WINDOW_STATE = path.join(DATA_DIR, 'window.json')
50
51
 
51
52
  let win
52
53
  let pollTimer
54
+ let petPointerTimer
53
55
  let config
54
56
  let doTick = null
55
57
  const W = 304
@@ -61,6 +63,8 @@ let trayBounds = null // last known tray icon rect, to anchor the popover
61
63
  let lastBlurHide = 0 // debounce: ignore the tray click that dismissed the popover
62
64
  let sessionPct = null // authoritative session % shown in the tray title
63
65
  let codexUsage = null // last Codex payload, null while it isn't enabled
66
+ let realUsageAt = 0
67
+ let codexReadAt = 0
64
68
  let realUsage = null // last OAuth usage payload — the % alerts trust when logged in
65
69
  let lastProgrammaticMove = 0 // ignore the 'moved' event our own setPosition triggers
66
70
  let displayChanging = 0 // ignore OS window-shuffles while a display (dis)connects
@@ -153,6 +157,96 @@ function notify(title, body, { silent = true } = {}) {
153
157
  n.show()
154
158
  }
155
159
 
160
+ // Explicit reminders survive restarts and belong to the account that armed them.
161
+ const REMINDERS_FILE = path.join(DATA_DIR, 'reset-reminders.json')
162
+ const resetReminders = createReminders({
163
+ now: () => Date.now(),
164
+ load: () => JSON.parse(fs.readFileSync(REMINDERS_FILE, 'utf8')),
165
+ save: (data) => {
166
+ fs.mkdirSync(DATA_DIR, { recursive: true })
167
+ const tmp = `${REMINDERS_FILE}.tmp`
168
+ fs.writeFileSync(tmp, JSON.stringify(data))
169
+ fs.renameSync(tmp, REMINDERS_FILE)
170
+ },
171
+ })
172
+ const reminderKey = (provider) => (provider === 'codex' ? 'codex' : `claude:${accounts.activeId()}`)
173
+ function sendReminders(error = null) {
174
+ if (!win || win.isDestroyed()) return
175
+ win.webContents.send('reminders', {
176
+ claude: resetReminders.list().find((r) => r.key === reminderKey('claude')) || null,
177
+ codex: resetReminders.list().find((r) => r.key === 'codex') || null,
178
+ error,
179
+ })
180
+ }
181
+ function cancelReminder(key) {
182
+ try {
183
+ resetReminders.cancel(key)
184
+ sendReminders()
185
+ return true
186
+ } catch (err) {
187
+ console.error('reset reminder:', err)
188
+ sendReminders('Could not cancel the reminder. Please try again.')
189
+ return false
190
+ }
191
+ }
192
+ function checkResetReminders() {
193
+ try {
194
+ for (const r of resetReminders.list()) {
195
+ const connected =
196
+ r.provider === 'codex'
197
+ ? config.codex?.enabled
198
+ : accounts.list().some((a) => r.key === `claude:${a.id}` && hasToken(a.id))
199
+ if (!connected && !cancelReminder(r.key)) return
200
+ }
201
+ const due = resetReminders.takeDue()
202
+ for (const r of due) {
203
+ const isCurrent = r.provider === 'codex' || r.key === reminderKey('claude')
204
+ const session = r.provider === 'codex' ? codexUsage?.session : realUsage?.session
205
+ const readAt = r.provider === 'codex' ? codexUsage?.limitsAt : realUsageAt
206
+ const confirmed = isCurrent && readAt >= r.at && session?.pct != null && session.pct < r.pct
207
+ const message = confirmed
208
+ ? `${r.label}: session budget is available again.`
209
+ : `${r.label}: the scheduled session reset time has arrived. Open usage to confirm the new budget.`
210
+ notify(
211
+ confirmed ? 'Your session budget is back' : 'Time to check your session budget',
212
+ message,
213
+ )
214
+ if (win && !win.isDestroyed())
215
+ win.webContents.send('reminder-due', { ...r, confirmed, isCurrent })
216
+ }
217
+ if (due.length) sendReminders()
218
+ } catch (err) {
219
+ console.error('reset reminder:', err)
220
+ }
221
+ }
222
+ ipcMain.on('set-reminder', (_e, provider, enabled) => {
223
+ if (!['claude', 'codex'].includes(provider) || typeof enabled !== 'boolean') return
224
+ const key = reminderKey(provider)
225
+ if (!enabled) return cancelReminder(key)
226
+ const cx = provider === 'codex'
227
+ const session = cx ? codexUsage?.session : realUsage?.session
228
+ const at = (cx ? codexReadAt : realUsageAt) + (session?.resetMs || 0)
229
+ const freshAt = cx ? codexUsage?.limitsAt : realUsageAt
230
+ const connected = cx ? config.codex?.enabled : auth.isConnected()
231
+ if (
232
+ !connected ||
233
+ !freshAt ||
234
+ Date.now() - freshAt > 15 * 60000 ||
235
+ !Number.isFinite(session?.pct)
236
+ ) {
237
+ return sendReminders('Wait for a fresh usage reading before setting a reminder.')
238
+ }
239
+ try {
240
+ const label = cx ? 'Codex' : `Claude · ${accounts.active().label || 'your account'}`
241
+ if (!resetReminders.arm({ key, provider, label, at, pct: session.pct })) {
242
+ return sendReminders('This reset time has passed. Wait for a fresh reading.')
243
+ }
244
+ sendReminders()
245
+ } catch {
246
+ sendReminders('Could not save the reminder. Please try again.')
247
+ }
248
+ })
249
+
156
250
  // the alert lines carry the reset, since "what do I do about it" is a question
157
251
  // about time left, not about the threshold that happened to fire
158
252
  function resetLine(resetMs, withRemaining) {
@@ -232,7 +326,14 @@ function checkWindowReset(config, pct) {
232
326
  if (pct == null) return
233
327
  const was = lastSessionPct
234
328
  lastSessionPct = pct
235
- if (config.alerts && was != null && was >= RESET_FROM && pct <= RESET_TO) {
329
+ if (
330
+ config.alerts &&
331
+ was != null &&
332
+ was >= RESET_FROM &&
333
+ pct <= RESET_TO &&
334
+ !resetReminders.list().some((r) => r.key === reminderKey('claude')) &&
335
+ !resetReminders.recentlyDelivered(reminderKey('claude'))
336
+ ) {
236
337
  notify('Session window reset', 'full budget again')
237
338
  }
238
339
  if (was == null || Math.round(was) !== Math.round(pct)) saveAlertState()
@@ -259,6 +360,7 @@ function applyAccount(acc) {
259
360
  lastSessionPct = null
260
361
  lastSeenTokens = null // the new account's first read is a baseline, not a spend
261
362
  loadAlertState()
363
+ sendReminders()
262
364
  }
263
365
 
264
366
  // `connected` is read off disk rather than through auth, which only ever knows
@@ -346,7 +448,10 @@ ipcMain.on('accounts-cancel-add', () => {
346
448
  switchAccount(from) // the empty slot is pruned on the way out
347
449
  })
348
450
  ipcMain.on('accounts-remove', (_e, id) => {
349
- if (accounts.remove(String(id))) sendAccounts()
451
+ if (accounts.remove(String(id))) {
452
+ cancelReminder(`claude:${id}`)
453
+ sendAccounts()
454
+ }
350
455
  })
351
456
 
352
457
  function createWindow() {
@@ -438,12 +543,14 @@ function createWindow() {
438
543
  try {
439
544
  const c = config.codex?.enabled ? codex.getCodexUsage() : null
440
545
  codexUsage = c
546
+ codexReadAt = Date.now()
441
547
  win.webContents.send('codex', c)
442
548
  checkCodexAlerts(config, c)
443
549
  updateTray()
444
550
  } catch (err) {
445
551
  console.error('codex:', err)
446
552
  }
553
+ checkResetReminders()
447
554
  }
448
555
  doTick = tick
449
556
 
@@ -451,6 +558,7 @@ function createWindow() {
451
558
  tick()
452
559
  win.webContents.send('config', publicConfig(config))
453
560
  win.webContents.send('version', app.getVersion())
561
+ sendReminders()
454
562
  win.webContents.send('auth-state', { connected: auth.isConnected() })
455
563
  sendAccounts()
456
564
  pollTimer = setInterval(tick, config.pollIntervalMs)
@@ -659,12 +767,38 @@ function updateTray() {
659
767
  // send real usage to the renderer and refresh the tray title in one place
660
768
  function pushRealUsage(u) {
661
769
  realUsage = u
770
+ realUsageAt = u ? Date.now() : 0
662
771
  sessionPct = u?.session ? u.session.pct : null
663
772
  updateTray()
664
773
  checkScopedAlerts(config, u)
665
774
  if (win && !win.isDestroyed()) win.webContents.send('real-usage', u)
666
775
  }
667
776
 
777
+ // Native drag regions swallow renderer pointer events. Poll only in pet-only
778
+ // mode so its hover still works while the sprite remains a native drag handle.
779
+ ipcMain.on('pet-pointer-watch', (_e, enabled) => {
780
+ if (typeof enabled !== 'boolean') return
781
+ clearInterval(petPointerTimer)
782
+ petPointerTimer = null
783
+ if (!enabled) return
784
+ let last // only a moved cursor crosses IPC, not ten idle messages a second
785
+ petPointerTimer = setInterval(() => {
786
+ if (!win || win.isDestroyed()) return
787
+ let point = null
788
+ if (win.isVisible()) {
789
+ const cursor = screen.getCursorScreenPoint()
790
+ const bounds = win.getContentBounds()
791
+ const x = cursor.x - bounds.x
792
+ const y = cursor.y - bounds.y
793
+ if (x >= 0 && y >= 0 && x < bounds.width && y < bounds.height) point = { x, y }
794
+ }
795
+ const key = point ? `${point.x},${point.y}` : ''
796
+ if (key === last) return
797
+ last = key
798
+ win.webContents.send('pet-pointer', point)
799
+ }, 100)
800
+ })
801
+
668
802
  // resize the window to fit the content
669
803
  ipcMain.on('resize', (_e, w, h) => {
670
804
  if (!win || win.isDestroyed()) return
@@ -742,9 +876,11 @@ function scheduleUsagePoll() {
742
876
  if (auth.isConnected()) usageTimer = setTimeout(pollUsage, usageBackoff)
743
877
  }
744
878
  async function pollUsage() {
879
+ const accountId = accounts.activeId()
745
880
  lastPollAt = Date.now()
746
881
  try {
747
882
  const u = await auth.fetchUsage()
883
+ if (accountId !== accounts.activeId()) return
748
884
  usageBackoff = 5 * 60 * 1000
749
885
  authFails = 0
750
886
  pushRealUsage(u)
@@ -756,6 +892,7 @@ async function pollUsage() {
756
892
  sendProfile()
757
893
  }
758
894
  } catch (e) {
895
+ if (accountId !== accounts.activeId()) return
759
896
  if (e && e.status === 429) {
760
897
  usageBackoff = Math.min(usageBackoff * 2, 30 * 60 * 1000)
761
898
  } else if (e && e.status === 401) {
@@ -870,6 +1007,7 @@ ipcMain.on('auth-code', async (_e, code) => {
870
1007
  }
871
1008
  })
872
1009
  ipcMain.on('auth-logout', () => {
1010
+ cancelReminder(reminderKey('claude'))
873
1011
  auth.clear()
874
1012
  clearTimeout(usageTimer)
875
1013
  pushRealUsage(null)
@@ -885,7 +1023,10 @@ ipcMain.on('auth-logout', () => {
885
1023
  function setCodexEnabled(on) {
886
1024
  writeConfigPatch({ codex: { ...(config.codex || {}), enabled: on } })
887
1025
  config = loadConfig()
888
- if (!on) codexUsage = null
1026
+ if (!on) {
1027
+ codexUsage = null
1028
+ cancelReminder('codex')
1029
+ }
889
1030
  if (win && !win.isDestroyed()) win.webContents.send('config', publicConfig(config))
890
1031
  if (doTick) doTick()
891
1032
  updateTray()
@@ -1029,6 +1170,7 @@ app.whenReady().then(() => {
1029
1170
 
1030
1171
  app.on('window-all-closed', () => {
1031
1172
  if (pollTimer) clearInterval(pollTimer)
1173
+ clearInterval(petPointerTimer)
1032
1174
  fs.unwatchFile(DEBUG_FILE)
1033
1175
  destroyTray()
1034
1176
  app.quit()
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clauddy",
3
3
  "desktopName": "clauddy.desktop",
4
- "version": "1.25.0",
4
+ "version": "1.27.0",
5
5
  "description": "A cute desktop pet that tracks your Claude Code usage",
6
6
  "main": "main.js",
7
7
  "bin": {
@@ -15,6 +15,7 @@
15
15
  "auth.js",
16
16
  "accounts.js",
17
17
  "codex.js",
18
+ "reminders.js",
18
19
  "config.json",
19
20
  "renderer"
20
21
  ],
@@ -75,6 +76,7 @@
75
76
  "auth.js",
76
77
  "accounts.js",
77
78
  "codex.js",
79
+ "reminders.js",
78
80
  "config.json",
79
81
  "renderer/**",
80
82
  "build/trayTemplate.png",
package/preload.js CHANGED
@@ -1,6 +1,11 @@
1
1
  const { contextBridge, ipcRenderer } = require('electron')
2
2
 
3
3
  contextBridge.exposeInMainWorld('api', {
4
+ watchPetPointer: (enabled) => ipcRenderer.send('pet-pointer-watch', enabled),
5
+ onPetPointer: (cb) => ipcRenderer.on('pet-pointer', (_e, point) => cb(point)),
6
+ setReminder: (provider, enabled) => ipcRenderer.send('set-reminder', provider, enabled),
7
+ onReminders: (cb) => ipcRenderer.on('reminders', (_e, state) => cb(state)),
8
+ onReminderDue: (cb) => ipcRenderer.on('reminder-due', (_e, event) => cb(event)),
4
9
  onUsage: (cb) => ipcRenderer.on('usage', (_e, data) => cb(data)),
5
10
  onError: (cb) => ipcRenderer.on('usage-error', (_e, msg) => cb(msg)),
6
11
  onConfig: (cb) => ipcRenderer.on('config', (_e, cfg) => cb(cfg)),
package/reminders.js ADDED
@@ -0,0 +1,53 @@
1
+ // One-shot reset reminders, independent of automatic threshold alerts.
2
+ function createReminders({ load, save, now = Date.now }) {
3
+ let pending = []
4
+ let delivered = {}
5
+ const valid = (r) =>
6
+ r &&
7
+ ['claude', 'codex'].includes(r.provider) &&
8
+ typeof r.key === 'string' &&
9
+ typeof r.label === 'string' &&
10
+ Number.isFinite(r.at) &&
11
+ Number.isFinite(r.pct)
12
+ try {
13
+ const data = load()
14
+ pending = Array.isArray(data?.pending) ? data.pending.filter(valid) : []
15
+ delivered = data?.delivered || {}
16
+ } catch {}
17
+ function persist(nextPending, nextDelivered = delivered) {
18
+ const recent = Object.fromEntries(
19
+ Object.entries(nextDelivered).filter(([, at]) => Number.isFinite(at) && now() - at < 3600000),
20
+ )
21
+ save({ pending: nextPending, delivered: recent })
22
+ pending = nextPending
23
+ delivered = recent
24
+ }
25
+ return {
26
+ list: () => pending.map((r) => ({ ...r })),
27
+ arm(reminder) {
28
+ if (!valid(reminder) || reminder.at <= now() || reminder.at > now() + 7 * 86400000)
29
+ return false
30
+ persist([...pending.filter((r) => r.key !== reminder.key), { ...reminder }])
31
+ return true
32
+ },
33
+ cancel(key) {
34
+ const found = pending.some((r) => r.key === key)
35
+ if (found) persist(pending.filter((r) => r.key !== key))
36
+ },
37
+ takeDue() {
38
+ const due = pending.filter((r) => r.at <= now())
39
+ if (!due.length) return []
40
+ const nextDelivered = { ...delivered }
41
+ for (const r of due) nextDelivered[r.key] = now()
42
+ // Consume before delivering: restart must not replay it.
43
+ persist(
44
+ pending.filter((r) => r.at > now()),
45
+ nextDelivered,
46
+ )
47
+ return due
48
+ },
49
+ recentlyDelivered: (key) => Number.isFinite(delivered[key]) && now() - delivered[key] < 3600000,
50
+ }
51
+ }
52
+
53
+ module.exports = { createReminders }
@@ -0,0 +1,48 @@
1
+ // A quiet transition detector. Startup and reconnects establish a baseline;
2
+ // inactivity is a pause, never proof that an agent finished its task.
3
+ ;((root) => {
4
+ function createActivityTracker({ now = Date.now, cooldown = 30000 } = {}) {
5
+ const seen = new Map()
6
+ let lastCue = -Infinity
7
+ return {
8
+ forget(provider) {
9
+ seen.delete(provider)
10
+ },
11
+ observe(provider, data) {
12
+ if (!data) {
13
+ seen.delete(provider)
14
+ return null
15
+ }
16
+ const next = { active: !!data.active, activity: data.activity || 'working' }
17
+ const previous = seen.get(provider)
18
+ seen.set(provider, next)
19
+ if (!previous) return null
20
+ const changed =
21
+ next.active !== previous.active || (next.active && next.activity !== previous.activity)
22
+ if (!changed || now() - lastCue < cooldown) return null
23
+ lastCue = now()
24
+ return { provider, activity: next.active ? next.activity : 'paused' }
25
+ },
26
+ }
27
+ }
28
+ // Activity freshness is separate from quota freshness: Codex can work while
29
+ // its last rate-limit reading is old. Ignore abandoned activity snapshots.
30
+ const FRESH_MS = 60000
31
+ const SLEEP_MS = 300000 // main's default sleepThresholdMs; Codex has no flag of its own
32
+ function currentActivity(claude, codex, now = Date.now()) {
33
+ const fresh = (at) => Number.isFinite(at) && now - at <= FRESH_MS
34
+ const providers = []
35
+ if (claude?.active && fresh(claude.ts)) providers.push('claude')
36
+ if (codex?.active && fresh(codex.lastSeen)) providers.push('codex')
37
+ // only Claude's logs say *what* it's doing; two workers share one scene
38
+ const activity = providers.join() === 'claude' ? claude.activity || 'working' : 'working'
39
+ const asleep = [
40
+ claude && !!claude.sleeping,
41
+ codex && !!codex.lastSeen && now - codex.lastSeen >= SLEEP_MS,
42
+ ].filter((v) => v !== null && v !== undefined)
43
+ return { providers, activity, sleeping: asleep.length > 0 && asleep.every(Boolean) }
44
+ }
45
+ const api = { createActivityTracker, currentActivity }
46
+ if (typeof module === 'object' && module.exports) module.exports = api
47
+ else root.Companion = api
48
+ })(globalThis)
@@ -46,6 +46,9 @@
46
46
  <path d="M7 7h10v10M7 17 17 7" />
47
47
  </svg>
48
48
  </button>
49
+ <button id="pet-only-toggle" title="Just the pet" aria-label="Just the pet">
50
+ <svg class="ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 4h14v4h3v10h-3v3h-3v-3H8v3H5v-3H2V8h3Z"/><path d="M8 11v2m8-2v2"/></svg>
51
+ </button>
49
52
  <button id="min" title="Minimize / expand">
50
53
  <svg class="ic ic-min" viewBox="0 0 24 24" aria-hidden="true">
51
54
  <path d="M5 12h14" />
@@ -202,7 +205,7 @@
202
205
  <span id="mini-acct-name"></span>
203
206
  <span id="mini-acct-plan" class="plan-badge" hidden></span>
204
207
  </div>
205
- <div class="mini-line"><span id="mini-dot"></span><span id="mini-text">idle</span></div>
208
+ <div class="mini-line"><span id="mini-dot"></span><span id="mini-workers" class="activity-logos" role="img" hidden><span data-provider="claude"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#logo-claude" /></svg></span><span data-provider="codex"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#logo-openai" /></svg></span></span><span id="mini-text">idle</span></div>
206
209
  <div id="mini-pct-row"><span id="mini-pct">0%</span> <span class="lbl">session</span></div>
207
210
  <!-- Dual-service dock: two glanceable readings, one shared pet. -->
208
211
  <div id="mini-dual">
@@ -214,8 +217,17 @@
214
217
  </button>
215
218
  </div>
216
219
  <div id="mini-reset" hidden></div>
220
+ <button type="button" id="mini-reminder" class="reminder-toggle" hidden></button>
217
221
  </div>
218
222
 
223
+ <div id="pet-activity" class="activity-logos" role="img" hidden><span data-provider="claude"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#logo-claude" /></svg></span><span data-provider="codex"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#logo-openai" /></svg></span></div>
224
+ <section id="pet-glance" aria-label="Session usage">
225
+ <button type="button" data-provider="claude" id="glance-claude"><svg class="mini-logo" viewBox="0 0 24 24" aria-hidden="true"><use href="#logo-claude" /></svg><span>Claude</span><b id="glance-claude-value">—</b></button>
226
+ <button type="button" data-provider="codex" id="glance-codex"><svg class="mini-logo" viewBox="0 0 24 24" aria-hidden="true"><use href="#logo-openai" /></svg><span>Codex</span><b id="glance-codex-value">—</b></button>
227
+ <span id="glance-empty" hidden>Connect an account in Settings.</span>
228
+ <button type="button" id="pet-restore" aria-label="Open compact monitor" title="Open compact monitor"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 4h6v6M20 4l-7 7M10 20H4v-6M4 20l7-7" /></svg></button>
229
+ </section>
230
+ <div id="companion-notice" role="status" hidden></div>
219
231
  <!-- collapsible content -->
220
232
  <div id="content">
221
233
  <!-- status -->
@@ -241,6 +253,7 @@
241
253
  <div class="meter-top"><span>current session</span><span id="session-pct">0%</span></div>
242
254
  <div class="track"><div class="fill" id="session-fill"></div></div>
243
255
  <div class="sub" id="session-sub">no active session</div>
256
+ <button type="button" id="reminder-toggle" class="reminder-toggle" hidden></button>
244
257
  <div class="sub proj" id="session-proj" hidden></div>
245
258
  </div>
246
259
  <div class="meter">
@@ -418,6 +431,7 @@
418
431
  </div>
419
432
  <script src="burn.js"></script>
420
433
  <script src="voice.js"></script>
434
+ <script src="companion.js"></script>
421
435
  <script src="pet.js"></script>
422
436
  </body>
423
437
  </html>
@@ -261,9 +261,6 @@ body.collapsed.state-stressed:not(.dual) #pet {
261
261
  body.collapsed.dual.state-stressed #compact-flames {
262
262
  top: -30px;
263
263
  }
264
- body.collapsed.dual.state-stressed #pet {
265
- transform: translateY(4px);
266
- }
267
264
  body.collapsed.state-stressed .compact-flame {
268
265
  transform-box: fill-box;
269
266
  transform-origin: 50% 100%;
package/renderer/pet.js CHANGED
@@ -503,7 +503,7 @@ function moodOf(st) {
503
503
  // mini face has room for a glance, not a sentence, so it gets the short one.
504
504
  // `force` is the simulator's: it skips the gates but still respects the mute
505
505
  function say(line, { headline = false, mood = 'normal', force = false } = {}) {
506
- if (!line) return false
506
+ if (!line || document.body.classList.contains('pet-only')) return false
507
507
  const collapsed = document.body.classList.contains('collapsed')
508
508
  const text = typeof line === 'string' ? line : collapsed ? line.short : line.text
509
509
  if (!force) {
@@ -743,8 +743,8 @@ el('mute').addEventListener('click', (e) => {
743
743
 
744
744
  // ---- services ---------------------------------------------------------------
745
745
  // Claude and Codex are monitored side by side, but the panel shows one at a
746
- // time: the tabs under the pet pick which one owns the chip, the pet, the
747
- // meters, the breakdowns and the Usage arrow.
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.
748
748
  const PROVIDER_KEY = 'clauddy.provider'
749
749
  let codexData = null // last Codex payload; null while Codex isn't enabled
750
750
  let pickedProvider = (() => {
@@ -780,6 +780,20 @@ const CODEX_SLEEP_MS = 5 * 60000 // main's default sleepThresholdMs
780
780
  const codexStale = (c) =>
781
781
  !!c && (c.session?.expired || (c.limitsAt != null && Date.now() - c.limitsAt > STALE_MS))
782
782
 
783
+ // 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
785
+ 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)
791
+ return {
792
+ pct: Math.max(0, ...known.map(([, p]) => p)),
793
+ providers: known.filter(([, p]) => p >= from).map(([k]) => k),
794
+ }
795
+ }
796
+
783
797
  // Codex's numbers in the shape the panel draws; its logs have no activity or tok/min
784
798
  function codexView(c) {
785
799
  const idleFor = c.lastSeen ? Date.now() - c.lastSeen : null
@@ -899,12 +913,207 @@ el('harness-tabs').addEventListener('keydown', (e) => {
899
913
  el(`tab-${providers[next]}`).focus()
900
914
  })
901
915
 
916
+ // ---- companion: pet-only size, reset reminders, and provider reactions -------
917
+ const SIZE_KEY = 'clauddy.size'
918
+ let sizeLoaded = false
919
+ let peekTimer = null
920
+ let nativePetInside = null
921
+ let noticeTimer = null
922
+ let reminders = { claude: null, codex: null }
923
+ const activityTracker = Companion.createActivityTracker()
924
+
925
+ function setPetPeek(on) {
926
+ if (!document.body.classList.contains('pet-only')) return
927
+ clearTimeout(peekTimer)
928
+ document.body.classList.toggle('pet-peek', on)
929
+ }
930
+ function setDisplaySize(size, remember = true) {
931
+ if (!['expanded', 'compact', 'pet'].includes(size)) size = 'expanded'
932
+ if (currentConfig.mode === 'menubar' && size === 'pet') size = 'compact'
933
+ hideBubble()
934
+ clearTimeout(peekTimer)
935
+ document.body.classList.remove('pet-peek')
936
+ document.body.classList.toggle('collapsed', size !== 'expanded')
937
+ document.body.classList.toggle('pet-only', size === 'pet')
938
+ nativePetInside = null
939
+ window.api.watchPetPointer(size === 'pet')
940
+ el('pet').tabIndex = size === 'pet' ? 0 : -1
941
+ if (size === 'pet') {
942
+ el('pet').setAttribute('role', 'button')
943
+ el('pet').setAttribute('aria-label', 'Drag to move; press Enter to open compact monitor')
944
+ } else {
945
+ el('pet').removeAttribute('role')
946
+ el('pet').removeAttribute('aria-label')
947
+ }
948
+ if (remember) {
949
+ try {
950
+ localStorage.setItem(SIZE_KEY, size)
951
+ } catch {}
952
+ }
953
+ paint()
954
+ fitSize()
955
+ }
956
+ el('pet-only-toggle').addEventListener('click', () => setDisplaySize('pet'))
957
+ el('pet-restore').addEventListener('click', () => setDisplaySize('compact'))
958
+ window.api.onPetPointer((point) => {
959
+ if (!document.body.classList.contains('pet-only')) return
960
+ const contains = (rect) =>
961
+ point &&
962
+ point.x >= rect.left &&
963
+ point.x <= rect.right &&
964
+ point.y >= rect.top &&
965
+ point.y <= rect.bottom
966
+ // Keep the preview open across the small gap to its controls. It opens from
967
+ // the sprite, not from the otherwise empty space below it.
968
+ const inside = !!(
969
+ contains(el('pet').getBoundingClientRect()) ||
970
+ (document.body.classList.contains('pet-peek') && contains(el('card').getBoundingClientRect()))
971
+ )
972
+ if (inside === nativePetInside) return
973
+ nativePetInside = inside
974
+ clearTimeout(peekTimer)
975
+ if (inside) setPetPeek(true)
976
+ else peekTimer = setTimeout(() => setPetPeek(false), 180)
977
+ })
978
+ window.addEventListener('blur', () => setPetPeek(false))
979
+ el('card').addEventListener('focusin', () => setPetPeek(true))
980
+ el('card').addEventListener('focusout', (e) => {
981
+ if (!el('card').contains(e.relatedTarget)) setPetPeek(false)
982
+ })
983
+ el('pet').addEventListener('keydown', (e) => {
984
+ if (!document.body.classList.contains('pet-only')) return
985
+ if (e.key === 'Enter' || e.key === ' ') {
986
+ e.preventDefault()
987
+ setDisplaySize('compact')
988
+ } else if (e.key === 'Escape') {
989
+ el('pet').blur()
990
+ setPetPeek(false)
991
+ }
992
+ })
993
+ for (const button of document.querySelectorAll('#pet-glance button[data-provider]')) {
994
+ button.addEventListener('click', () => pickProvider(button.dataset.provider))
995
+ }
996
+ 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
1000
+ const button = el(`glance-${provider}`)
1001
+ button.hidden = !connected
1002
+ button.setAttribute('aria-pressed', String(provider === view))
1003
+ button.classList.toggle('urgent', session?.pct >= warnAt())
1004
+ el(`glance-${provider}-value`).textContent = pctText(session?.pct)
1005
+ const stale = provider === 'codex' && codexStale(codexData)
1006
+ button.title = stale
1007
+ ? 'Last recorded usage · waiting for a fresh reading'
1008
+ : `Show ${provider} usage`
1009
+ button.classList.toggle('stale-reading', !!stale)
1010
+ }
1011
+ el('glance-empty').hidden = claudeOn() || codexOn()
1012
+ const session = view === 'claude' ? realUsage?.session : codexData?.session
1013
+ const reminder = reminders[view]
1014
+ const stale =
1015
+ view === 'codex'
1016
+ ? !codexData?.limitsAt || codexStale(codexData)
1017
+ : !realUsageReadAt || Date.now() - realUsageReadAt > STALE_MS
1018
+ const available = session?.pct != null && session.resetMs > 0 && !stale
1019
+ const name = view === 'codex' ? 'Codex' : 'Claude'
1020
+ for (const id of ['reminder-toggle', 'mini-reminder']) {
1021
+ const button = el(id)
1022
+ button.hidden = !reminder && (!available || session.pct < warnAt())
1023
+ button.setAttribute('aria-pressed', String(!!reminder))
1024
+ button.textContent = reminder ? '✓ Reminder on' : 'Notify at reset'
1025
+ 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`
1028
+ }
1029
+ }
1030
+ function companionNotice(text, provider = null) {
1031
+ clearTimeout(noticeTimer)
1032
+ const notice = el('companion-notice')
1033
+ notice.textContent = text
1034
+ notice.title = text
1035
+ notice.dataset.provider = provider || ''
1036
+ notice.hidden = false
1037
+ fitSize()
1038
+ noticeTimer = setTimeout(() => {
1039
+ notice.hidden = true
1040
+ fitSize()
1041
+ }, 5000)
1042
+ }
1043
+ for (const id of ['reminder-toggle', 'mini-reminder']) {
1044
+ el(id).addEventListener('click', () => {
1045
+ const provider = viewProvider()
1046
+ window.api.setReminder(provider, !reminders[provider])
1047
+ })
1048
+ }
1049
+ window.api.onReminders((state) => {
1050
+ reminders = state || { claude: null, codex: null }
1051
+ if (state?.error) companionNotice(state.error)
1052
+ paintCompanion(viewProvider())
1053
+ fitSize()
1054
+ })
1055
+ window.api.onReminderDue((event) => {
1056
+ if (!event.isCurrent) return
1057
+ const name = event.provider === 'codex' ? 'Codex' : 'Claude'
1058
+ companionNotice(
1059
+ event.confirmed ? `${name} · budget is back!` : `${name} · reset time reached`,
1060
+ event.provider,
1061
+ )
1062
+ if (event.confirmed) celebrate()
1063
+ })
1064
+ function reactToProvider(provider, data) {
1065
+ // no caption: the eyes glance toward whichever service changed
1066
+ const cue = activityTracker.observe(provider, data)
1067
+ if (!cue || document.body.classList.contains('settings-open')) return
1068
+ if (!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
1069
+ const x = provider === 'codex' ? 3 : -3
1070
+ el('eyes').animate(
1071
+ [
1072
+ { transform: 'translateX(0)' },
1073
+ { transform: `translateX(${x}px)`, offset: 0.3 },
1074
+ { transform: `translateX(${x}px)`, offset: 0.7 },
1075
+ { transform: 'translateX(0)' },
1076
+ ],
1077
+ { duration: 1100, easing: 'ease-in-out' },
1078
+ )
1079
+ }
1080
+ }
1081
+
1082
+ function paintActivity() {
1083
+ const activity = Companion.currentActivity(
1084
+ claudeOn() ? claudeActivityData : null,
1085
+ codexOn() ? codexData : null,
1086
+ )
1087
+ paintLogos(activity.providers, activity.activity)
1088
+ return activity
1089
+ }
1090
+
1091
+ // the logos under the pet name who it's about: the workers, or the service on fire
1092
+ function paintLogos(providers, caption) {
1093
+ const names = providers.map((p) => (p === 'claude' ? 'Claude' : 'Codex')).join(' + ')
1094
+ const label = names ? `${names} · ${caption}` : ''
1095
+ for (const id of ['pet-activity', 'mini-workers']) {
1096
+ const badge = el(id)
1097
+ badge.hidden = !names
1098
+ badge.setAttribute('aria-label', label)
1099
+ for (const logo of badge.querySelectorAll('[data-provider]')) {
1100
+ logo.hidden = !providers.includes(logo.dataset.provider)
1101
+ }
1102
+ }
1103
+ el('mini-dot').hidden = providers.length > 0 && document.body.classList.contains('collapsed')
1104
+ }
1105
+
902
1106
  // main render
903
1107
  let prevState = null
904
1108
  const prevPct = {} // per service: a switch must not read as a reset
905
1109
  let lastData = null
1110
+ // Claude's activity, kept apart from lastData: an account switch or a logout
1111
+ // must drop the old account's worker at once, not a tick later
1112
+ let claudeActivityData = null
906
1113
  function render(d) {
1114
+ if (claudeOn()) reactToProvider('claude', d)
907
1115
  lastData = d
1116
+ claudeActivityData = claudeOn() ? d : null
908
1117
  noteSession(d)
909
1118
  // the burn trail is Claude's, fed on every Claude tick whatever is on screen
910
1119
  const cl = realUsage
@@ -918,6 +1127,7 @@ function paint() {
918
1127
  if (!d0) return
919
1128
  const view = viewProvider()
920
1129
  paintServices(view)
1130
+ paintCompanion(view)
921
1131
  const isCodex = view === 'codex' && !!codexData
922
1132
  const cv = isCodex ? codexView(codexData) : null
923
1133
  const d = isCodex ? cv.d : d0
@@ -945,6 +1155,25 @@ function paint() {
945
1155
  : 'idle'
946
1156
  const acts = ['editing', 'reading', 'planning', 'running', 'researching', 'delegating', 'waiting']
947
1157
  let curActivity = d.activity
1158
+ const activity = paintActivity()
1159
+ const collapsedNow = document.body.classList.contains('collapsed')
1160
+ if (collapsedNow) {
1161
+ // work wins; at rest, the hottest connected session still sets the mood,
1162
+ // and its logo takes the status dot's place
1163
+ const heat = sessionHeat(fireAt)
1164
+ st = activity.providers.length
1165
+ ? 'working'
1166
+ : heat.pct >= 100
1167
+ ? 'tired'
1168
+ : heat.pct >= fireAt
1169
+ ? 'stressed'
1170
+ : activity.sleeping
1171
+ ? 'sleeping'
1172
+ : 'idle'
1173
+ if (st === 'tired') paintLogos(sessionHeat(100).providers, 'maxed out')
1174
+ else if (st === 'stressed') paintLogos(heat.providers, 'on fire')
1175
+ curActivity = activity.providers.length ? activity.activity : null
1176
+ }
948
1177
  // dev override from `./pet <state>` (base states or an activity name)
949
1178
  if (debugState) {
950
1179
  const map = {
@@ -1051,7 +1280,9 @@ function paint() {
1051
1280
  el('month-total').textContent = d.monthTokens == null ? '—' : `${fmtTokens(d.monthTokens)} tokens`
1052
1281
  paintChip()
1053
1282
 
1054
- currentRate = d.tokensPerMin || 0
1283
+ // compact eats at Claude's pace only while Claude is one of the workers
1284
+ const claudeRate = !collapsedNow || activity.providers.includes('claude')
1285
+ currentRate = claudeRate ? (collapsedNow ? d0 : d).tokensPerMin || 0 : 0
1055
1286
  if (st === 'working') {
1056
1287
  if (!eating) {
1057
1288
  eating = true
@@ -1075,7 +1306,8 @@ function fitSize() {
1075
1306
  const zoom = Number.parseFloat(document.body.style.zoom) || 1
1076
1307
  // The dual-service dock trades height for a little horizontal room.
1077
1308
  const dual = document.body.classList.contains('dual')
1078
- const w = (collapsed ? (dual ? 240 : 168) : 304) * zoom
1309
+ const petOnly = document.body.classList.contains('pet-only')
1310
+ const w = (petOnly ? 168 : collapsed ? (dual ? 240 : 192) : 304) * zoom
1079
1311
  // offsetHeight never reflects a CSS zoom applied to an ancestor (confirmed
1080
1312
  // empirically against this Electron build) — so measure the unzoomed content
1081
1313
  // height, then scale the whole thing (content + margin) ourselves
@@ -1096,6 +1328,7 @@ function applyZoom(z) {
1096
1328
 
1097
1329
  let currentConfig = {}
1098
1330
  let realUsage = null
1331
+ let realUsageReadAt = 0
1099
1332
  let debugState = null
1100
1333
  window.api.onDebugState((o) => {
1101
1334
  const s = o?.state
@@ -1115,6 +1348,17 @@ window.api.onConfig((cfg) => {
1115
1348
  document.body.classList.toggle('is-menubar', currentConfig.mode === 'menubar')
1116
1349
  document.body.classList.toggle('codex-on', !!currentConfig.codex)
1117
1350
  if (!currentConfig.codex) codexData = null
1351
+ if (!currentConfig.codex) activityTracker.forget('codex')
1352
+ if (!sizeLoaded) {
1353
+ sizeLoaded = true
1354
+ let size = 'expanded'
1355
+ try {
1356
+ size = localStorage.getItem(SIZE_KEY) || size
1357
+ } catch {}
1358
+ setDisplaySize(size, false)
1359
+ } else if (currentConfig.mode === 'menubar' && document.body.classList.contains('pet-only')) {
1360
+ setDisplaySize('compact', false)
1361
+ }
1118
1362
  heardConfig = true
1119
1363
  paintMute()
1120
1364
  paint()
@@ -1123,16 +1367,20 @@ window.api.onConfig((cfg) => {
1123
1367
  })
1124
1368
  window.api.onCodex((c) => {
1125
1369
  codexData = c || null
1370
+ reactToProvider('codex', codexOn() ? c : null)
1126
1371
  paint()
1127
1372
  })
1128
1373
  window.api.onRealUsage((u) => {
1129
1374
  realUsage = u || null
1375
+ realUsageReadAt = u ? Date.now() : 0
1130
1376
  if (lastData) render(lastData)
1131
1377
  })
1132
1378
  window.api.onAuthState((s) => {
1133
1379
  const on = !!s?.connected
1134
1380
  document.body.classList.toggle('auth-on', on)
1135
1381
  if (!on) {
1382
+ activityTracker.forget('claude')
1383
+ claudeActivityData = null
1136
1384
  realUsage = null
1137
1385
  document.body.classList.remove('live')
1138
1386
  // main sends this before it opens the browser for a new login, so the
@@ -1342,8 +1590,13 @@ function switchAccount(id) {
1342
1590
  }
1343
1591
 
1344
1592
  window.api.onAccounts((a) => {
1593
+ if (lastAccounts?.active !== a?.active) {
1594
+ activityTracker.forget('claude')
1595
+ claudeActivityData = null
1596
+ }
1345
1597
  pendingRemove = null
1346
1598
  renderAccounts(a)
1599
+ paint()
1347
1600
  })
1348
1601
 
1349
1602
  // closing the panel while a login is pending gives up on it: main puts us back
@@ -1636,7 +1889,7 @@ function populateSettings() {
1636
1889
  }
1637
1890
  function openSettings() {
1638
1891
  hideBubble()
1639
- document.body.classList.remove('collapsed')
1892
+ setDisplaySize('expanded', false)
1640
1893
  populateSettings()
1641
1894
  document.body.classList.add('settings-open')
1642
1895
  fitSize()
@@ -1728,18 +1981,19 @@ el('set-save').addEventListener('click', () => {
1728
1981
  })
1729
1982
  el('min').addEventListener('click', () => {
1730
1983
  hideBubble()
1731
- document.body.classList.toggle('collapsed')
1732
- fitSize()
1984
+ setDisplaySize(document.body.classList.contains('collapsed') ? 'expanded' : 'compact')
1733
1985
  })
1734
1986
  // double-click the pet to collapse / expand
1735
1987
  el('pet').addEventListener('dblclick', () => {
1988
+ if (document.body.classList.contains('pet-only')) return
1736
1989
  hideBubble()
1737
- document.body.classList.toggle('collapsed')
1738
- fitSize()
1990
+ setDisplaySize(document.body.classList.contains('collapsed') ? 'expanded' : 'compact')
1739
1991
  })
1740
1992
 
1741
1993
  // poke the pet -> bouncy squish + hearts
1742
- el('pet').addEventListener('click', () => pokePet())
1994
+ el('pet').addEventListener('click', () => {
1995
+ if (!document.body.classList.contains('pet-only')) pokePet()
1996
+ })
1743
1997
 
1744
1998
  // eyes follow the cursor
1745
1999
  const eyesG = el('eyes')
@@ -1785,6 +2039,10 @@ if (typeof module === 'object' && module.exports) {
1785
2039
  say,
1786
2040
  hideBubble,
1787
2041
  soundOn,
2042
+ setDisplaySize,
2043
+ setPetPeek,
2044
+ paintCompanion,
2045
+ activityTracker,
1788
2046
  blipper,
1789
2047
  }
1790
2048
  }
@@ -316,6 +316,11 @@ body.collapsed #account-chip {
316
316
  display: none;
317
317
  }
318
318
  /* collapsed: the account rides up on the button line, like the chip does */
319
+ /* collapsed, the buttons share the account's line: centre them on it
320
+ (top 6px + half its 19px), not on the expanded 38px title band */
321
+ body.collapsed #controls {
322
+ height: 31px;
323
+ }
319
324
  body.collapsed #mini-acct {
320
325
  position: absolute;
321
326
  top: 6px;
@@ -1238,6 +1243,18 @@ body.collapsed #card {
1238
1243
  width: 144px;
1239
1244
  padding: 12px 12px 13px;
1240
1245
  }
1246
+ /* one service: three buttons plus the account need a title band of their
1247
+ own, like the dual dock has, or they sit on the ring's arc */
1248
+ body.collapsed:not(.dual):not(.pet-only) #card {
1249
+ width: 168px;
1250
+ padding-top: 32px;
1251
+ }
1252
+ body.collapsed:not(.dual):not(.pet-only) #stage {
1253
+ margin-top: 0;
1254
+ }
1255
+ body.collapsed:not(.dual):not(.pet-only) #mini-acct-name {
1256
+ max-width: 46px;
1257
+ }
1241
1258
  body.collapsed #stage {
1242
1259
  margin-top: 9px;
1243
1260
  }
@@ -3020,3 +3037,258 @@ body.collapsed #mute {
3020
3037
  animation: none;
3021
3038
  }
3022
3039
  }
3040
+
3041
+ /* Companion controls stay quiet until they are useful. */
3042
+ #controls #pet-only-toggle,
3043
+ #pet-glance {
3044
+ display: none;
3045
+ }
3046
+ body.collapsed:not(.pet-only):not(.is-menubar) #controls #pet-only-toggle {
3047
+ display: grid;
3048
+ }
3049
+ .reminder-toggle {
3050
+ display: inline-flex;
3051
+ align-items: center;
3052
+ min-height: 24px;
3053
+ margin-top: 4px;
3054
+ padding: 2px 6px;
3055
+ border: 1px solid var(--hair);
3056
+ border-radius: 5px;
3057
+ background: #ffffff04;
3058
+ color: #cfc0ac;
3059
+ font: inherit;
3060
+ font-size: 10px;
3061
+ cursor: pointer;
3062
+ -webkit-app-region: no-drag;
3063
+ }
3064
+ .reminder-toggle[hidden] {
3065
+ display: none;
3066
+ }
3067
+ .reminder-toggle[aria-pressed="true"] {
3068
+ color: #bed4aa;
3069
+ border-color: #bed4aa33;
3070
+ }
3071
+ .reminder-toggle:hover {
3072
+ background: #ffffff0c;
3073
+ }
3074
+ #mini-reminder {
3075
+ font-size: 8px;
3076
+ min-height: 20px;
3077
+ }
3078
+ body.collapsed.dual #mini-reminder {
3079
+ float: right;
3080
+ }
3081
+ #companion-notice {
3082
+ grid-column: 1 / -1;
3083
+ padding: 6px 8px;
3084
+ margin-top: 6px;
3085
+ border-radius: 6px;
3086
+ background: #ffffff07;
3087
+ color: #d5c6b6;
3088
+ font-size: 10px;
3089
+ line-height: 1.4;
3090
+ text-align: center;
3091
+ -webkit-app-region: no-drag;
3092
+ }
3093
+ #companion-notice[data-provider="codex"] {
3094
+ color: #c1d1b8;
3095
+ }
3096
+ #companion-notice[hidden],
3097
+ body.settings-open #companion-notice {
3098
+ display: none;
3099
+ }
3100
+ /* Hover only fades the glance in; neither the window nor the pet moves. */
3101
+ body.collapsed.pet-only #card {
3102
+ width: 144px;
3103
+ height: 166px;
3104
+ padding: 0;
3105
+ display: block;
3106
+ background: transparent;
3107
+ border-color: transparent;
3108
+ box-shadow: none;
3109
+ overflow: visible;
3110
+ -webkit-app-region: no-drag;
3111
+ }
3112
+ body.collapsed.pet-only #stage {
3113
+ position: absolute;
3114
+ width: 100px;
3115
+ height: 102px;
3116
+ right: 21px;
3117
+ bottom: 38px;
3118
+ margin: 0;
3119
+ background: none;
3120
+ }
3121
+ body.collapsed.pet-only #pet {
3122
+ zoom: 1.05;
3123
+ transform: none;
3124
+ transition: none;
3125
+ cursor: grab;
3126
+ -webkit-app-region: drag;
3127
+ }
3128
+ body.collapsed.pet-only #pet:active {
3129
+ cursor: grabbing;
3130
+ }
3131
+ body.collapsed.pet-only :is(#mini, #controls, #ring, #ring-pct, #companion-notice, #bubble) {
3132
+ display: none;
3133
+ }
3134
+ body.collapsed.pet-only #pet-glance {
3135
+ position: absolute;
3136
+ display: flex;
3137
+ align-items: center;
3138
+ justify-content: center;
3139
+ gap: 6px;
3140
+ left: 50%;
3141
+ width: max-content;
3142
+ max-width: calc(100% - 2px);
3143
+ transform: translateX(-50%);
3144
+ bottom: 2px;
3145
+ min-height: 32px;
3146
+ padding: 2px 5px;
3147
+ border: 1px solid transparent;
3148
+ border-radius: 10px;
3149
+ font-size: 10px;
3150
+ color: var(--muted);
3151
+ opacity: 0;
3152
+ visibility: hidden;
3153
+ transition:
3154
+ opacity 160ms ease,
3155
+ visibility 160ms,
3156
+ background 160ms ease,
3157
+ border-color 160ms ease;
3158
+ -webkit-app-region: no-drag;
3159
+ }
3160
+ body.collapsed.pet-only.pet-peek #pet-glance {
3161
+ background: #221e1bec;
3162
+ border-color: #ffffff0d;
3163
+ opacity: 1;
3164
+ visibility: visible;
3165
+ }
3166
+ #pet-glance #pet-restore {
3167
+ width: 26px;
3168
+ height: 26px;
3169
+ flex-shrink: 0;
3170
+ justify-content: center;
3171
+ border-radius: 6px;
3172
+ color: #c8bcaf;
3173
+ opacity: 0.65;
3174
+ }
3175
+ #pet-glance #pet-restore:hover,
3176
+ #pet-glance #pet-restore:focus-visible {
3177
+ background: #ffffff12;
3178
+ color: #f1e5d8;
3179
+ opacity: 1;
3180
+ }
3181
+ #pet-glance button {
3182
+ position: relative;
3183
+ display: flex;
3184
+ align-items: center;
3185
+ gap: 5px;
3186
+ padding: 0;
3187
+ border: 0;
3188
+ background: transparent;
3189
+ color: #a99e91;
3190
+ font: inherit;
3191
+ cursor: pointer;
3192
+ }
3193
+ /* Keep provider names available to assistive technology. */
3194
+ #pet-glance button > span {
3195
+ position: absolute;
3196
+ width: 1px;
3197
+ height: 1px;
3198
+ overflow: hidden;
3199
+ clip-path: inset(50%);
3200
+ white-space: nowrap;
3201
+ }
3202
+ #pet-glance button[hidden] {
3203
+ display: none;
3204
+ }
3205
+ #pet-glance button[aria-pressed="true"],
3206
+ #pet-glance button:hover {
3207
+ color: #f1e5d8;
3208
+ }
3209
+ #pet-glance button:focus-visible {
3210
+ outline: 1px solid #e6a07f;
3211
+ outline-offset: 3px;
3212
+ border-radius: 3px;
3213
+ }
3214
+ #pet-glance b {
3215
+ font-size: 11px;
3216
+ font-weight: 500;
3217
+ font-variant-numeric: tabular-nums;
3218
+ }
3219
+ #pet-glance .urgent b {
3220
+ color: #f3a38b;
3221
+ }
3222
+ #pet-glance .stale-reading b::after {
3223
+ content: "*";
3224
+ font-size: 9px;
3225
+ vertical-align: super;
3226
+ }
3227
+ body.collapsed.pet-only #pet:focus-visible {
3228
+ outline: 1px solid #e6a07f;
3229
+ outline-offset: 4px;
3230
+ }
3231
+ @media (prefers-reduced-motion: reduce) {
3232
+ body.collapsed.pet-only #pet-glance,
3233
+ body.collapsed.pet-only #pet-glance > * {
3234
+ transition: none;
3235
+ }
3236
+ }
3237
+ /* A short reaction temporarily occupies the account label, so the compact
3238
+ widget stays still instead of growing for every activity transition. */
3239
+ body.collapsed.dual:not(.pet-only) #companion-notice {
3240
+ position: absolute;
3241
+ top: 12px;
3242
+ left: 12px;
3243
+ right: 78px;
3244
+ margin: 0;
3245
+ padding: 0;
3246
+ background: none;
3247
+ font-size: 8px;
3248
+ text-align: left;
3249
+ white-space: nowrap;
3250
+ overflow: hidden;
3251
+ text-overflow: ellipsis;
3252
+ }
3253
+ body.collapsed.dual:has(#companion-notice:not([hidden])) #mini-acct {
3254
+ visibility: hidden;
3255
+ }
3256
+
3257
+ /* Activity belongs to the pet, independently of the selected usage tab. */
3258
+ .activity-logos {
3259
+ display: inline-flex;
3260
+ align-items: center;
3261
+ gap: 3px;
3262
+ flex-shrink: 0;
3263
+ }
3264
+ .activity-logos [data-provider="claude"] {
3265
+ color: #df9777;
3266
+ }
3267
+ .activity-logos [data-provider="codex"] {
3268
+ color: #b9c8b0;
3269
+ }
3270
+ .activity-logos svg {
3271
+ display: block;
3272
+ width: 11px;
3273
+ height: 11px;
3274
+ fill: currentColor;
3275
+ }
3276
+ .activity-logos[hidden],
3277
+ .activity-logos [hidden],
3278
+ #mini-dot[hidden],
3279
+ #pet-activity {
3280
+ display: none;
3281
+ }
3282
+ body.collapsed.pet-only #pet-activity:not([hidden]) {
3283
+ position: absolute;
3284
+ top: 30px;
3285
+ right: 0;
3286
+ display: flex;
3287
+ flex-direction: column;
3288
+ gap: 5px;
3289
+ padding: 5px;
3290
+ border-radius: 8px;
3291
+ background: #221e1be6;
3292
+ border: 1px solid #ffffff0d;
3293
+ pointer-events: none;
3294
+ }