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 +11 -0
- package/main.js +145 -3
- package/package.json +3 -1
- package/preload.js +5 -0
- package/reminders.js +53 -0
- package/renderer/companion.js +48 -0
- package/renderer/index.html +15 -1
- package/renderer/motion.css +0 -3
- package/renderer/pet.js +269 -11
- package/renderer/style.css +272 -0
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 (
|
|
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)))
|
|
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)
|
|
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.
|
|
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)
|
package/renderer/index.html
CHANGED
|
@@ -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/motion.css
CHANGED
|
@@ -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:
|
|
747
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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', () =>
|
|
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
|
}
|
package/renderer/style.css
CHANGED
|
@@ -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
|
+
}
|