clauddy 1.25.0 → 1.26.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 pet 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.26.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>
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 = (() => {
@@ -899,12 +899,203 @@ el('harness-tabs').addEventListener('keydown', (e) => {
899
899
  el(`tab-${providers[next]}`).focus()
900
900
  })
901
901
 
902
+ // ---- companion: pet-only size, reset reminders, and provider reactions -------
903
+ const SIZE_KEY = 'clauddy.size'
904
+ let sizeLoaded = false
905
+ let peekTimer = null
906
+ let nativePetInside = null
907
+ let noticeTimer = null
908
+ let reminders = { claude: null, codex: null }
909
+ const activityTracker = Companion.createActivityTracker()
910
+
911
+ function setPetPeek(on) {
912
+ if (!document.body.classList.contains('pet-only')) return
913
+ clearTimeout(peekTimer)
914
+ document.body.classList.toggle('pet-peek', on)
915
+ }
916
+ function setDisplaySize(size, remember = true) {
917
+ if (!['expanded', 'compact', 'pet'].includes(size)) size = 'expanded'
918
+ if (currentConfig.mode === 'menubar' && size === 'pet') size = 'compact'
919
+ hideBubble()
920
+ clearTimeout(peekTimer)
921
+ document.body.classList.remove('pet-peek')
922
+ document.body.classList.toggle('collapsed', size !== 'expanded')
923
+ document.body.classList.toggle('pet-only', size === 'pet')
924
+ nativePetInside = null
925
+ window.api.watchPetPointer(size === 'pet')
926
+ el('pet').tabIndex = size === 'pet' ? 0 : -1
927
+ if (size === 'pet') {
928
+ el('pet').setAttribute('role', 'button')
929
+ el('pet').setAttribute('aria-label', 'Drag to move; press Enter to open compact monitor')
930
+ } else {
931
+ el('pet').removeAttribute('role')
932
+ el('pet').removeAttribute('aria-label')
933
+ }
934
+ if (remember) {
935
+ try {
936
+ localStorage.setItem(SIZE_KEY, size)
937
+ } catch {}
938
+ }
939
+ paint()
940
+ fitSize()
941
+ }
942
+ el('pet-only-toggle').addEventListener('click', () => setDisplaySize('pet'))
943
+ el('pet-restore').addEventListener('click', () => setDisplaySize('compact'))
944
+ window.api.onPetPointer((point) => {
945
+ if (!document.body.classList.contains('pet-only')) return
946
+ const contains = (rect) =>
947
+ point &&
948
+ point.x >= rect.left &&
949
+ point.x <= rect.right &&
950
+ point.y >= rect.top &&
951
+ point.y <= rect.bottom
952
+ // Keep the preview open across the small gap to its controls. It opens from
953
+ // the sprite, not from the otherwise empty space below it.
954
+ const inside = !!(
955
+ contains(el('pet').getBoundingClientRect()) ||
956
+ (document.body.classList.contains('pet-peek') && contains(el('card').getBoundingClientRect()))
957
+ )
958
+ if (inside === nativePetInside) return
959
+ nativePetInside = inside
960
+ clearTimeout(peekTimer)
961
+ if (inside) setPetPeek(true)
962
+ else peekTimer = setTimeout(() => setPetPeek(false), 180)
963
+ })
964
+ window.addEventListener('blur', () => setPetPeek(false))
965
+ el('card').addEventListener('focusin', () => setPetPeek(true))
966
+ el('card').addEventListener('focusout', (e) => {
967
+ if (!el('card').contains(e.relatedTarget)) setPetPeek(false)
968
+ })
969
+ el('pet').addEventListener('keydown', (e) => {
970
+ if (!document.body.classList.contains('pet-only')) return
971
+ if (e.key === 'Enter' || e.key === ' ') {
972
+ e.preventDefault()
973
+ setDisplaySize('compact')
974
+ } else if (e.key === 'Escape') {
975
+ el('pet').blur()
976
+ setPetPeek(false)
977
+ }
978
+ })
979
+ for (const button of document.querySelectorAll('#pet-glance button[data-provider]')) {
980
+ button.addEventListener('click', () => pickProvider(button.dataset.provider))
981
+ }
982
+ 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
986
+ const button = el(`glance-${provider}`)
987
+ button.hidden = !connected
988
+ button.setAttribute('aria-pressed', String(provider === view))
989
+ button.classList.toggle('urgent', session?.pct >= warnAt())
990
+ el(`glance-${provider}-value`).textContent = pctText(session?.pct)
991
+ const stale = provider === 'codex' && codexStale(codexData)
992
+ button.title = stale
993
+ ? 'Last recorded usage · waiting for a fresh reading'
994
+ : `Show ${provider} usage`
995
+ button.classList.toggle('stale-reading', !!stale)
996
+ }
997
+ el('glance-empty').hidden = claudeOn() || codexOn()
998
+ const session = view === 'claude' ? realUsage?.session : codexData?.session
999
+ const reminder = reminders[view]
1000
+ const stale =
1001
+ view === 'codex'
1002
+ ? !codexData?.limitsAt || codexStale(codexData)
1003
+ : !realUsageReadAt || Date.now() - realUsageReadAt > STALE_MS
1004
+ const available = session?.pct != null && session.resetMs > 0 && !stale
1005
+ const name = view === 'codex' ? 'Codex' : 'Claude'
1006
+ for (const id of ['reminder-toggle', 'mini-reminder']) {
1007
+ const button = el(id)
1008
+ button.hidden = !reminder && (!available || session.pct < warnAt())
1009
+ button.setAttribute('aria-pressed', String(!!reminder))
1010
+ button.textContent = reminder ? '✓ Reminder on' : 'Notify at reset'
1011
+ 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`
1014
+ }
1015
+ }
1016
+ function companionNotice(text, provider = null) {
1017
+ clearTimeout(noticeTimer)
1018
+ const notice = el('companion-notice')
1019
+ notice.textContent = text
1020
+ notice.title = text
1021
+ notice.dataset.provider = provider || ''
1022
+ notice.hidden = false
1023
+ fitSize()
1024
+ noticeTimer = setTimeout(() => {
1025
+ notice.hidden = true
1026
+ fitSize()
1027
+ }, 5000)
1028
+ }
1029
+ for (const id of ['reminder-toggle', 'mini-reminder']) {
1030
+ el(id).addEventListener('click', () => {
1031
+ const provider = viewProvider()
1032
+ window.api.setReminder(provider, !reminders[provider])
1033
+ })
1034
+ }
1035
+ window.api.onReminders((state) => {
1036
+ reminders = state || { claude: null, codex: null }
1037
+ if (state?.error) companionNotice(state.error)
1038
+ paintCompanion(viewProvider())
1039
+ fitSize()
1040
+ })
1041
+ window.api.onReminderDue((event) => {
1042
+ if (!event.isCurrent) return
1043
+ const name = event.provider === 'codex' ? 'Codex' : 'Claude'
1044
+ companionNotice(
1045
+ event.confirmed ? `${name} · budget is back!` : `${name} · reset time reached`,
1046
+ event.provider,
1047
+ )
1048
+ if (event.confirmed) celebrate()
1049
+ })
1050
+ function reactToProvider(provider, data) {
1051
+ // no caption: the eyes glance toward whichever service changed
1052
+ const cue = activityTracker.observe(provider, data)
1053
+ if (!cue || document.body.classList.contains('settings-open')) return
1054
+ if (!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
1055
+ const x = provider === 'codex' ? 3 : -3
1056
+ el('eyes').animate(
1057
+ [
1058
+ { transform: 'translateX(0)' },
1059
+ { transform: `translateX(${x}px)`, offset: 0.3 },
1060
+ { transform: `translateX(${x}px)`, offset: 0.7 },
1061
+ { transform: 'translateX(0)' },
1062
+ ],
1063
+ { duration: 1100, easing: 'ease-in-out' },
1064
+ )
1065
+ }
1066
+ }
1067
+
1068
+ 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}` : ''
1075
+ for (const id of ['pet-activity', 'mini-workers']) {
1076
+ const badge = el(id)
1077
+ badge.hidden = !names
1078
+ badge.setAttribute('aria-label', label)
1079
+ for (const logo of badge.querySelectorAll('[data-provider]')) {
1080
+ logo.hidden = !activity.providers.includes(logo.dataset.provider)
1081
+ }
1082
+ }
1083
+ el('mini-dot').hidden =
1084
+ activity.providers.length > 0 && document.body.classList.contains('collapsed')
1085
+ return activity
1086
+ }
1087
+
902
1088
  // main render
903
1089
  let prevState = null
904
1090
  const prevPct = {} // per service: a switch must not read as a reset
905
1091
  let lastData = null
1092
+ // Claude's activity, kept apart from lastData: an account switch or a logout
1093
+ // must drop the old account's worker at once, not a tick later
1094
+ let claudeActivityData = null
906
1095
  function render(d) {
1096
+ if (claudeOn()) reactToProvider('claude', d)
907
1097
  lastData = d
1098
+ claudeActivityData = claudeOn() ? d : null
908
1099
  noteSession(d)
909
1100
  // the burn trail is Claude's, fed on every Claude tick whatever is on screen
910
1101
  const cl = realUsage
@@ -918,6 +1109,7 @@ function paint() {
918
1109
  if (!d0) return
919
1110
  const view = viewProvider()
920
1111
  paintServices(view)
1112
+ paintCompanion(view)
921
1113
  const isCodex = view === 'codex' && !!codexData
922
1114
  const cv = isCodex ? codexView(codexData) : null
923
1115
  const d = isCodex ? cv.d : d0
@@ -945,6 +1137,12 @@ function paint() {
945
1137
  : 'idle'
946
1138
  const acts = ['editing', 'reading', 'planning', 'running', 'researching', 'delegating', 'waiting']
947
1139
  let curActivity = d.activity
1140
+ const activity = paintActivity()
1141
+ const collapsedNow = document.body.classList.contains('collapsed')
1142
+ if (collapsedNow) {
1143
+ st = activity.providers.length ? 'working' : activity.sleeping ? 'sleeping' : 'idle'
1144
+ curActivity = activity.providers.length ? activity.activity : null
1145
+ }
948
1146
  // dev override from `./pet <state>` (base states or an activity name)
949
1147
  if (debugState) {
950
1148
  const map = {
@@ -1051,7 +1249,9 @@ function paint() {
1051
1249
  el('month-total').textContent = d.monthTokens == null ? '—' : `${fmtTokens(d.monthTokens)} tokens`
1052
1250
  paintChip()
1053
1251
 
1054
- currentRate = d.tokensPerMin || 0
1252
+ // compact eats at Claude's pace only while Claude is one of the workers
1253
+ const claudeRate = !collapsedNow || activity.providers.includes('claude')
1254
+ currentRate = claudeRate ? (collapsedNow ? d0 : d).tokensPerMin || 0 : 0
1055
1255
  if (st === 'working') {
1056
1256
  if (!eating) {
1057
1257
  eating = true
@@ -1075,7 +1275,8 @@ function fitSize() {
1075
1275
  const zoom = Number.parseFloat(document.body.style.zoom) || 1
1076
1276
  // The dual-service dock trades height for a little horizontal room.
1077
1277
  const dual = document.body.classList.contains('dual')
1078
- const w = (collapsed ? (dual ? 240 : 168) : 304) * zoom
1278
+ const petOnly = document.body.classList.contains('pet-only')
1279
+ const w = (petOnly ? 168 : collapsed ? (dual ? 240 : 192) : 304) * zoom
1079
1280
  // offsetHeight never reflects a CSS zoom applied to an ancestor (confirmed
1080
1281
  // empirically against this Electron build) — so measure the unzoomed content
1081
1282
  // height, then scale the whole thing (content + margin) ourselves
@@ -1096,6 +1297,7 @@ function applyZoom(z) {
1096
1297
 
1097
1298
  let currentConfig = {}
1098
1299
  let realUsage = null
1300
+ let realUsageReadAt = 0
1099
1301
  let debugState = null
1100
1302
  window.api.onDebugState((o) => {
1101
1303
  const s = o?.state
@@ -1115,6 +1317,17 @@ window.api.onConfig((cfg) => {
1115
1317
  document.body.classList.toggle('is-menubar', currentConfig.mode === 'menubar')
1116
1318
  document.body.classList.toggle('codex-on', !!currentConfig.codex)
1117
1319
  if (!currentConfig.codex) codexData = null
1320
+ if (!currentConfig.codex) activityTracker.forget('codex')
1321
+ if (!sizeLoaded) {
1322
+ sizeLoaded = true
1323
+ let size = 'expanded'
1324
+ try {
1325
+ size = localStorage.getItem(SIZE_KEY) || size
1326
+ } catch {}
1327
+ setDisplaySize(size, false)
1328
+ } else if (currentConfig.mode === 'menubar' && document.body.classList.contains('pet-only')) {
1329
+ setDisplaySize('compact', false)
1330
+ }
1118
1331
  heardConfig = true
1119
1332
  paintMute()
1120
1333
  paint()
@@ -1123,16 +1336,20 @@ window.api.onConfig((cfg) => {
1123
1336
  })
1124
1337
  window.api.onCodex((c) => {
1125
1338
  codexData = c || null
1339
+ reactToProvider('codex', codexOn() ? c : null)
1126
1340
  paint()
1127
1341
  })
1128
1342
  window.api.onRealUsage((u) => {
1129
1343
  realUsage = u || null
1344
+ realUsageReadAt = u ? Date.now() : 0
1130
1345
  if (lastData) render(lastData)
1131
1346
  })
1132
1347
  window.api.onAuthState((s) => {
1133
1348
  const on = !!s?.connected
1134
1349
  document.body.classList.toggle('auth-on', on)
1135
1350
  if (!on) {
1351
+ activityTracker.forget('claude')
1352
+ claudeActivityData = null
1136
1353
  realUsage = null
1137
1354
  document.body.classList.remove('live')
1138
1355
  // main sends this before it opens the browser for a new login, so the
@@ -1342,8 +1559,13 @@ function switchAccount(id) {
1342
1559
  }
1343
1560
 
1344
1561
  window.api.onAccounts((a) => {
1562
+ if (lastAccounts?.active !== a?.active) {
1563
+ activityTracker.forget('claude')
1564
+ claudeActivityData = null
1565
+ }
1345
1566
  pendingRemove = null
1346
1567
  renderAccounts(a)
1568
+ paint()
1347
1569
  })
1348
1570
 
1349
1571
  // closing the panel while a login is pending gives up on it: main puts us back
@@ -1636,7 +1858,7 @@ function populateSettings() {
1636
1858
  }
1637
1859
  function openSettings() {
1638
1860
  hideBubble()
1639
- document.body.classList.remove('collapsed')
1861
+ setDisplaySize('expanded', false)
1640
1862
  populateSettings()
1641
1863
  document.body.classList.add('settings-open')
1642
1864
  fitSize()
@@ -1728,18 +1950,19 @@ el('set-save').addEventListener('click', () => {
1728
1950
  })
1729
1951
  el('min').addEventListener('click', () => {
1730
1952
  hideBubble()
1731
- document.body.classList.toggle('collapsed')
1732
- fitSize()
1953
+ setDisplaySize(document.body.classList.contains('collapsed') ? 'expanded' : 'compact')
1733
1954
  })
1734
1955
  // double-click the pet to collapse / expand
1735
1956
  el('pet').addEventListener('dblclick', () => {
1957
+ if (document.body.classList.contains('pet-only')) return
1736
1958
  hideBubble()
1737
- document.body.classList.toggle('collapsed')
1738
- fitSize()
1959
+ setDisplaySize(document.body.classList.contains('collapsed') ? 'expanded' : 'compact')
1739
1960
  })
1740
1961
 
1741
1962
  // poke the pet -> bouncy squish + hearts
1742
- el('pet').addEventListener('click', () => pokePet())
1963
+ el('pet').addEventListener('click', () => {
1964
+ if (!document.body.classList.contains('pet-only')) pokePet()
1965
+ })
1743
1966
 
1744
1967
  // eyes follow the cursor
1745
1968
  const eyesG = el('eyes')
@@ -1785,6 +2008,10 @@ if (typeof module === 'object' && module.exports) {
1785
2008
  say,
1786
2009
  hideBubble,
1787
2010
  soundOn,
2011
+ setDisplaySize,
2012
+ setPetPeek,
2013
+ paintCompanion,
2014
+ activityTracker,
1788
2015
  blipper,
1789
2016
  }
1790
2017
  }
@@ -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
+ }