clauddy 1.17.1 → 1.19.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 +26 -1
- package/accounts.js +145 -0
- package/auth.js +17 -6
- package/main.js +268 -27
- package/package.json +3 -1
- package/preload.js +5 -0
- package/renderer/index.html +15 -5
- package/renderer/pet.js +98 -7
- package/renderer/style.css +112 -0
- package/usage.js +31 -5
package/README.md
CHANGED
|
@@ -29,6 +29,16 @@ The session/weekly **%** comes straight from your Anthropic account, so it match
|
|
|
29
29
|
|
|
30
30
|
The token is saved locally (see [Data & privacy](#data--privacy)) and refreshed automatically. **Until you connect**, the limits area shows a _"Connect your account"_ prompt instead of percentages.
|
|
31
31
|
|
|
32
|
+
### Several subscriptions
|
|
33
|
+
|
|
34
|
+
Got more than one Claude account — say a personal Pro and a Max from work? **⚙ Settings → "+ Add another account"** opens the same browser login, and the token it brings back lands in a new slot. Once two accounts exist, a list appears in Settings (and an **Account** submenu on the tray icon) to switch between them. Give up halfway and the empty slot disappears on its own — the list only ever holds accounts you actually logged into.
|
|
35
|
+
|
|
36
|
+
The widget follows **one account at a time**: the one you pick is the one whose % is shown, whose logs are counted, and the only one that can notify you. Each account keeps its own token and its own armed alerts, so switching never replays a notification you already dismissed elsewhere. Everything else — window position, display mode, zoom, thresholds — is shared.
|
|
37
|
+
|
|
38
|
+
Removing an account deletes its token from disk. The first account can't be removed, and neither can the one you're currently on — switch away first.
|
|
39
|
+
|
|
40
|
+
> Prefer one widget per account instead? Setting `CLAUDE_CONFIG_DIR` still isolates a whole instance — token, settings and logs — so you can run two Clauddys side by side.
|
|
41
|
+
|
|
32
42
|
## Burn rate
|
|
33
43
|
|
|
34
44
|
Knowing you're at **82%** with **1h 12m** left on the window still leaves you doing arithmetic in your head. So Clauddy does it for you: it fits the slope of your recent usage and projects when you'd hit 100% — showing one extra line under the session bar:
|
|
@@ -177,7 +187,19 @@ Switching is instant — no restart. (On Windows/Linux the icon lives in the sys
|
|
|
177
187
|
|
|
178
188
|
## Alerts
|
|
179
189
|
|
|
180
|
-
Optional **macOS notifications
|
|
190
|
+
Optional **macOS notifications**, toggled (with their thresholds) in **⚙ Settings**:
|
|
191
|
+
|
|
192
|
+
| Notification | When |
|
|
193
|
+
| --- | --- |
|
|
194
|
+
| _Session at 82%_ — `2h 39m left · resets 6:50 PM` | Your session crosses a threshold (default **80%** and **95%**) |
|
|
195
|
+
| _Weekly usage at 84%_ — `resets Fri 7:00 AM` | Same, for the weekly limit |
|
|
196
|
+
| _Fable weekly at 84%_ — `resets Fri 7:00 AM` | Same, for a per-model weekly limit |
|
|
197
|
+
| _Session window reset_ — `full budget again` | A session you had pushed past 80% rolls over |
|
|
198
|
+
| _Clauddy lost access to your usage_ | The OAuth token expired or was revoked, so the % went back to being an estimate |
|
|
199
|
+
|
|
200
|
+
The last threshold you set is the only one that makes a sound; the earlier ones arrive silently. Clicking any of them brings the widget to the front. Each fires once and re-arms when usage drops back below — remembered across restarts, so relaunching at 85% doesn't repeat an alert you already dismissed.
|
|
201
|
+
|
|
202
|
+
Percentages come from your account when you're logged in, and fall back to the local token estimate when you're not.
|
|
181
203
|
|
|
182
204
|
The first alert asks macOS for permission; after that the app shows up in **System Settings > Notifications** like any other.
|
|
183
205
|
|
|
@@ -230,7 +252,10 @@ Everything lives on your machine, in `~/.claude-usage-monitor/`:
|
|
|
230
252
|
|
|
231
253
|
- `auth.json` — your OAuth token (file mode `600`, never committed)
|
|
232
254
|
- `config.json` — your alert settings
|
|
255
|
+
- `alerts.json` — which notifications are already armed, so a restart doesn't repeat them
|
|
256
|
+
- `accounts.json` — your list of accounts and which one is active
|
|
233
257
|
- `debug.json` — scratch file for the `./pet` simulator
|
|
258
|
+
- `accounts/<id>/` — the same `auth.json` + `alerts.json`, for each extra account
|
|
234
259
|
|
|
235
260
|
Nothing leaves your machine except the OAuth calls to Anthropic's own login and usage endpoints.
|
|
236
261
|
|
package/accounts.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Multiple Claude subscriptions in one widget.
|
|
2
|
+
//
|
|
3
|
+
// An account is a token plus, optionally, the Claude config dir whose logs it
|
|
4
|
+
// reads. The first one ("default") keeps the paths the app has always used, so
|
|
5
|
+
// an existing install carries over untouched; every extra account gets its own
|
|
6
|
+
// folder under `accounts/` for auth.json and alerts.json.
|
|
7
|
+
//
|
|
8
|
+
// Only the active account is polled — and therefore only it can notify.
|
|
9
|
+
const fs = require('node:fs')
|
|
10
|
+
const path = require('node:path')
|
|
11
|
+
const os = require('node:os')
|
|
12
|
+
|
|
13
|
+
const BASE_DIR = process.env.CLAUDE_CONFIG_DIR
|
|
14
|
+
? path.join(process.env.CLAUDE_CONFIG_DIR, 'usage-monitor')
|
|
15
|
+
: path.join(os.homedir(), '.claude-usage-monitor')
|
|
16
|
+
const FILE = path.join(BASE_DIR, 'accounts.json')
|
|
17
|
+
const DEFAULT_ID = 'default'
|
|
18
|
+
|
|
19
|
+
function defaults() {
|
|
20
|
+
return { active: DEFAULT_ID, accounts: [{ id: DEFAULT_ID, label: null, claudeDir: null }] }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sane(j) {
|
|
24
|
+
const list = (Array.isArray(j?.accounts) ? j.accounts : [])
|
|
25
|
+
.filter((a) => a && typeof a.id === 'string' && a.id)
|
|
26
|
+
.map((a) => ({
|
|
27
|
+
id: a.id,
|
|
28
|
+
label: typeof a.label === 'string' && a.label ? a.label : null,
|
|
29
|
+
claudeDir: typeof a.claudeDir === 'string' && a.claudeDir ? a.claudeDir : null,
|
|
30
|
+
}))
|
|
31
|
+
if (!list.some((a) => a.id === DEFAULT_ID)) list.unshift(defaults().accounts[0])
|
|
32
|
+
const active = list.some((a) => a.id === j?.active) ? j.active : DEFAULT_ID
|
|
33
|
+
return { active, accounts: list }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// re-read on every call instead of caching: the file is a handful of rows, it
|
|
37
|
+
// is read only when the account list is shown or changed, and a stale cache
|
|
38
|
+
// here would mean the widget polling a token it thinks it still has
|
|
39
|
+
function load() {
|
|
40
|
+
try {
|
|
41
|
+
return sane(JSON.parse(fs.readFileSync(FILE, 'utf8')))
|
|
42
|
+
} catch {
|
|
43
|
+
return defaults() // first run, or a corrupted file
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function save(state) {
|
|
48
|
+
try {
|
|
49
|
+
fs.mkdirSync(BASE_DIR, { recursive: true })
|
|
50
|
+
fs.writeFileSync(FILE, JSON.stringify(state, null, 2))
|
|
51
|
+
} catch {}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// where an account's own files (auth.json, alerts.json) live
|
|
55
|
+
function dataDirOf(id) {
|
|
56
|
+
return id === DEFAULT_ID ? BASE_DIR : path.join(BASE_DIR, 'accounts', id)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function list() {
|
|
60
|
+
return load().accounts.map((a) => ({ ...a }))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function activeId() {
|
|
64
|
+
return load().active
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function active() {
|
|
68
|
+
const s = load()
|
|
69
|
+
return s.accounts.find((a) => a.id === s.active) || s.accounts[0]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function find(s, id) {
|
|
73
|
+
return s.accounts.find((a) => a.id === id)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function setActive(id) {
|
|
77
|
+
const s = load()
|
|
78
|
+
const hit = find(s, id)
|
|
79
|
+
if (!hit) return null
|
|
80
|
+
s.active = id
|
|
81
|
+
save(s)
|
|
82
|
+
return hit
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// a fresh, logged-out account — the caller switches to it and runs the usual
|
|
86
|
+
// browser login, which is what gives it an identity
|
|
87
|
+
function add(claudeDir) {
|
|
88
|
+
const s = load()
|
|
89
|
+
// two clicks inside the same millisecond used to mint the same id, and the
|
|
90
|
+
// duplicate then looked like the account we were already on
|
|
91
|
+
let id = `acct-${Date.now().toString(36)}`
|
|
92
|
+
while (s.accounts.some((a) => a.id === id)) {
|
|
93
|
+
id = `acct-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
|
|
94
|
+
}
|
|
95
|
+
s.accounts.push({ id, label: null, claudeDir: claudeDir || null })
|
|
96
|
+
save(s)
|
|
97
|
+
return id
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// the login flow knows the email; that's a better name than "Account 2"
|
|
101
|
+
function label(id, text) {
|
|
102
|
+
const s = load()
|
|
103
|
+
const a = find(s, id)
|
|
104
|
+
if (!a || a.label === (text || null)) return
|
|
105
|
+
a.label = text || null
|
|
106
|
+
save(s)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function setClaudeDir(id, dir) {
|
|
110
|
+
const s = load()
|
|
111
|
+
const a = find(s, id)
|
|
112
|
+
if (!a) return
|
|
113
|
+
a.claudeDir = dir || null
|
|
114
|
+
save(s)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// removing an account takes its token with it — leaving a stray auth.json
|
|
118
|
+
// behind would be a credential nobody can see or revoke from the UI
|
|
119
|
+
function remove(id) {
|
|
120
|
+
const s = load()
|
|
121
|
+
if (id === DEFAULT_ID) return false // the original install's data: never dropped
|
|
122
|
+
if (id === s.active) return false // switch away first: the widget is showing it
|
|
123
|
+
const i = s.accounts.findIndex((a) => a.id === id)
|
|
124
|
+
if (i < 0) return false
|
|
125
|
+
s.accounts.splice(i, 1)
|
|
126
|
+
save(s)
|
|
127
|
+
try {
|
|
128
|
+
fs.rmSync(dataDirOf(id), { recursive: true, force: true })
|
|
129
|
+
} catch {}
|
|
130
|
+
return true
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
module.exports = {
|
|
134
|
+
BASE_DIR,
|
|
135
|
+
DEFAULT_ID,
|
|
136
|
+
dataDirOf,
|
|
137
|
+
list,
|
|
138
|
+
active,
|
|
139
|
+
activeId,
|
|
140
|
+
setActive,
|
|
141
|
+
add,
|
|
142
|
+
label,
|
|
143
|
+
setClaudeDir,
|
|
144
|
+
remove,
|
|
145
|
+
}
|
package/auth.js
CHANGED
|
@@ -17,7 +17,9 @@ const UA = 'claude-cli/2.1.181 (external, cli)'
|
|
|
17
17
|
const DATA_DIR = process.env.CLAUDE_CONFIG_DIR
|
|
18
18
|
? path.join(process.env.CLAUDE_CONFIG_DIR, 'usage-monitor')
|
|
19
19
|
: path.join(os.homedir(), '.claude-usage-monitor')
|
|
20
|
-
|
|
20
|
+
// mutable: with several accounts configured, each one keeps its token in its
|
|
21
|
+
// own dir and the widget switches between them at runtime
|
|
22
|
+
let tokenPath = path.join(DATA_DIR, 'auth.json')
|
|
21
23
|
|
|
22
24
|
const b64url = (buf) =>
|
|
23
25
|
buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
|
@@ -26,10 +28,19 @@ let tokens = null // { access_token, refresh_token, expires_at }
|
|
|
26
28
|
let pending = null // { verifier, state }
|
|
27
29
|
let profile = null // { email, name, plan } — cached account identity
|
|
28
30
|
|
|
31
|
+
// point the module at another account's dir — the cached token and profile
|
|
32
|
+
// belong to the previous one, so both are dropped
|
|
33
|
+
function setDataDir(dir) {
|
|
34
|
+
tokenPath = path.join(dir || DATA_DIR, 'auth.json')
|
|
35
|
+
tokens = null
|
|
36
|
+
profile = null
|
|
37
|
+
pending = null
|
|
38
|
+
}
|
|
39
|
+
|
|
29
40
|
function load() {
|
|
30
41
|
if (tokens) return tokens
|
|
31
42
|
try {
|
|
32
|
-
tokens = JSON.parse(fs.readFileSync(
|
|
43
|
+
tokens = JSON.parse(fs.readFileSync(tokenPath, 'utf8'))
|
|
33
44
|
} catch {
|
|
34
45
|
tokens = null
|
|
35
46
|
}
|
|
@@ -38,15 +49,15 @@ function load() {
|
|
|
38
49
|
function save(t) {
|
|
39
50
|
tokens = t
|
|
40
51
|
try {
|
|
41
|
-
fs.mkdirSync(path.dirname(
|
|
42
|
-
fs.writeFileSync(
|
|
52
|
+
fs.mkdirSync(path.dirname(tokenPath), { recursive: true })
|
|
53
|
+
fs.writeFileSync(tokenPath, JSON.stringify(t, null, 2), { mode: 0o600 })
|
|
43
54
|
} catch {}
|
|
44
55
|
}
|
|
45
56
|
function clear() {
|
|
46
57
|
tokens = null
|
|
47
58
|
profile = null
|
|
48
59
|
try {
|
|
49
|
-
fs.unlinkSync(
|
|
60
|
+
fs.unlinkSync(tokenPath)
|
|
50
61
|
} catch {}
|
|
51
62
|
}
|
|
52
63
|
function isConnected() {
|
|
@@ -228,4 +239,4 @@ async function fetchProfile() {
|
|
|
228
239
|
return profile
|
|
229
240
|
}
|
|
230
241
|
|
|
231
|
-
module.exports = { begin, complete, fetchUsage, fetchProfile, clear, isConnected }
|
|
242
|
+
module.exports = { begin, complete, fetchUsage, fetchProfile, clear, isConnected, setDataDir }
|
package/main.js
CHANGED
|
@@ -13,8 +13,10 @@ const path = require('node:path')
|
|
|
13
13
|
const fs = require('node:fs')
|
|
14
14
|
const os = require('node:os')
|
|
15
15
|
const { spawn } = require('node:child_process')
|
|
16
|
-
const
|
|
16
|
+
const usage = require('./usage')
|
|
17
|
+
const { getUsage } = usage
|
|
17
18
|
const auth = require('./auth')
|
|
19
|
+
const accounts = require('./accounts')
|
|
18
20
|
|
|
19
21
|
const REPO = 'renatoaug/claude-usage-monitor'
|
|
20
22
|
|
|
@@ -52,6 +54,7 @@ let currentMode = 'floating' // 'floating' widget | 'menubar' popover
|
|
|
52
54
|
let trayBounds = null // last known tray icon rect, to anchor the popover
|
|
53
55
|
let lastBlurHide = 0 // debounce: ignore the tray click that dismissed the popover
|
|
54
56
|
let sessionPct = null // authoritative session % shown in the tray title
|
|
57
|
+
let realUsage = null // last OAuth usage payload — the % alerts trust when logged in
|
|
55
58
|
let lastProgrammaticMove = 0 // ignore the 'moved' event our own setPosition triggers
|
|
56
59
|
let displayChanging = 0 // ignore OS window-shuffles while a display (dis)connects
|
|
57
60
|
|
|
@@ -92,46 +95,233 @@ function loadConfig() {
|
|
|
92
95
|
return defaults
|
|
93
96
|
}
|
|
94
97
|
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
98
|
+
// ---- alerts ------------------------------------------------------------------
|
|
99
|
+
// One notification per (scope × threshold), re-armed when usage drops back below.
|
|
100
|
+
// `armed` is persisted so relaunching at 85% doesn't repeat the 80% alert you
|
|
101
|
+
// already dismissed; it also carries the last session % so a reset is detectable
|
|
102
|
+
// across restarts.
|
|
103
|
+
// per account: two subscriptions have separate windows, so an alert armed on
|
|
104
|
+
// one says nothing about the other
|
|
105
|
+
let alertsFile = path.join(DATA_DIR, 'alerts.json')
|
|
106
|
+
let armed = new Set()
|
|
107
|
+
let lastSessionPct = null
|
|
108
|
+
function loadAlertState() {
|
|
109
|
+
try {
|
|
110
|
+
const j = JSON.parse(fs.readFileSync(alertsFile, 'utf8'))
|
|
111
|
+
armed = new Set(Array.isArray(j.armed) ? j.armed : [])
|
|
112
|
+
lastSessionPct = typeof j.lastSessionPct === 'number' ? j.lastSessionPct : null
|
|
113
|
+
} catch {} // first run, or a corrupted file: start from a clean slate
|
|
102
114
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
115
|
+
function saveAlertState() {
|
|
116
|
+
try {
|
|
117
|
+
fs.mkdirSync(path.dirname(alertsFile), { recursive: true })
|
|
118
|
+
fs.writeFileSync(alertsFile, JSON.stringify({ armed: [...armed], lastSessionPct }))
|
|
119
|
+
} catch {}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function fmtDuration(ms) {
|
|
123
|
+
const h = Math.floor(ms / 3600000)
|
|
124
|
+
const m = Math.floor((ms % 3600000) / 60000)
|
|
125
|
+
return h > 0 ? `${h}h ${m}m` : `${m}m`
|
|
126
|
+
}
|
|
127
|
+
// wall-clock time the window flips, in the machine's own locale + timezone
|
|
128
|
+
function fmtClock(ms) {
|
|
129
|
+
const at = new Date(Date.now() + ms)
|
|
130
|
+
const time = at.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
|
131
|
+
return ms >= 86400000 ? `${at.toLocaleDateString([], { weekday: 'short' })} ${time}` : time
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function notify(title, body, { silent = true } = {}) {
|
|
135
|
+
if (!Notification.isSupported()) return
|
|
136
|
+
const n = new Notification({ title, body, silent })
|
|
137
|
+
n.on('click', showWidget) // the obvious gesture: bring the pet to the front
|
|
138
|
+
n.show()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// the alert lines carry the reset, since "what do I do about it" is a question
|
|
142
|
+
// about time left, not about the threshold that happened to fire
|
|
143
|
+
function resetLine(resetMs, withRemaining) {
|
|
144
|
+
if (resetMs == null) return ''
|
|
145
|
+
const at = `resets ${fmtClock(resetMs)}`
|
|
146
|
+
return withRemaining && resetMs > 0 ? `${fmtDuration(resetMs)} left · ${at}` : at
|
|
110
147
|
}
|
|
148
|
+
|
|
149
|
+
// usage crossing a threshold. `scopes` are {label, pct, resetMs, session} rows;
|
|
150
|
+
// the real (OAuth) numbers are preferred over the local token estimate, which is
|
|
151
|
+
// only a budget guess and can be off by an order of magnitude.
|
|
111
152
|
function alertScopes(config, scopes) {
|
|
112
|
-
if (!config.alerts
|
|
153
|
+
if (!config.alerts) return
|
|
113
154
|
const ths = config.alertThresholds || [80, 95]
|
|
114
|
-
|
|
155
|
+
const top = Math.max(...ths)
|
|
156
|
+
let dirty = false
|
|
157
|
+
for (const { label, pct, resetMs, session } of scopes) {
|
|
158
|
+
if (pct == null) continue
|
|
115
159
|
for (const t of ths) {
|
|
116
|
-
const key = `${
|
|
160
|
+
const key = `${label}:${t}`
|
|
117
161
|
if (pct >= t) {
|
|
118
162
|
if (!armed.has(key)) {
|
|
119
163
|
armed.add(key)
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
164
|
+
dirty = true
|
|
165
|
+
const urgent = t === top
|
|
166
|
+
notify(
|
|
167
|
+
`${label} at ${Math.round(pct)}%${urgent ? ' — almost out' : ''}`,
|
|
168
|
+
resetLine(resetMs, session),
|
|
169
|
+
{ silent: !urgent }, // 80% is a heads-up; the last threshold earns a sound
|
|
170
|
+
)
|
|
125
171
|
}
|
|
126
|
-
} else {
|
|
127
|
-
|
|
172
|
+
} else if (armed.delete(key)) {
|
|
173
|
+
dirty = true // re-arm when it drops below
|
|
128
174
|
}
|
|
129
175
|
}
|
|
130
176
|
}
|
|
177
|
+
if (dirty) saveAlertState()
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function checkAlerts(config, d) {
|
|
181
|
+
const r = realUsage // authoritative when logged in; the estimate is the fallback
|
|
182
|
+
const session = r?.session ?? d.session
|
|
183
|
+
const week = r?.week ?? d.week
|
|
184
|
+
alertScopes(config, [
|
|
185
|
+
{ label: 'Session', pct: session.pct, resetMs: session.resetMs, session: true },
|
|
186
|
+
{ label: 'Weekly usage', pct: week.pct, resetMs: week.resetMs },
|
|
187
|
+
])
|
|
188
|
+
checkWindowReset(config, session.pct)
|
|
189
|
+
}
|
|
190
|
+
// per-model weekly limits only exist on the account side, so they're checked
|
|
191
|
+
// off the OAuth poll rather than the local tick
|
|
192
|
+
function checkScopedAlerts(config, u) {
|
|
193
|
+
alertScopes(
|
|
194
|
+
config,
|
|
195
|
+
(u?.scoped || []).map((s) => ({
|
|
196
|
+
label: `${s.label} weekly`,
|
|
197
|
+
pct: s.pct,
|
|
198
|
+
resetMs: s.resetMs,
|
|
199
|
+
})),
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// "you can work again" — only worth saying to someone who was actually near the
|
|
204
|
+
// ceiling, so a window flipping at 20% stays silent
|
|
205
|
+
const RESET_FROM = 80
|
|
206
|
+
const RESET_TO = 5
|
|
207
|
+
function checkWindowReset(config, pct) {
|
|
208
|
+
if (pct == null) return
|
|
209
|
+
const was = lastSessionPct
|
|
210
|
+
lastSessionPct = pct
|
|
211
|
+
if (config.alerts && was != null && was >= RESET_FROM && pct <= RESET_TO) {
|
|
212
|
+
notify('Session window reset', 'full budget again')
|
|
213
|
+
}
|
|
214
|
+
if (was == null || Math.round(was) !== Math.round(pct)) saveAlertState()
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// the token going dead is a silent failure otherwise: the widget quietly falls
|
|
218
|
+
// back to the local estimate and keeps showing a number the user trusts
|
|
219
|
+
function alertAuthLost(config) {
|
|
220
|
+
if (!config.alerts) return
|
|
221
|
+
notify('Clauddy lost access to your usage', 'open Settings to reconnect')
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---- accounts ----------------------------------------------------------------
|
|
225
|
+
// One widget, several Claude subscriptions. Switching rebinds the token store
|
|
226
|
+
// (auth), the log dir (usage) and the alert state — everything that is "whose
|
|
227
|
+
// usage is this" — while the window itself (position, mode, zoom, settings)
|
|
228
|
+
// stays global, because there is only one pet.
|
|
229
|
+
function applyAccount(acc) {
|
|
230
|
+
const dir = accounts.dataDirOf(acc.id)
|
|
231
|
+
alertsFile = path.join(dir, 'alerts.json')
|
|
232
|
+
auth.setDataDir(dir)
|
|
233
|
+
usage.setClaudeDir(acc.claudeDir)
|
|
234
|
+
armed = new Set()
|
|
235
|
+
lastSessionPct = null
|
|
236
|
+
loadAlertState()
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// `connected` is read off disk rather than through auth, which only ever knows
|
|
240
|
+
// about the active account
|
|
241
|
+
function accountsPayload() {
|
|
242
|
+
return {
|
|
243
|
+
active: accounts.activeId(),
|
|
244
|
+
accounts: accounts.list().map((a) => ({
|
|
245
|
+
id: a.id,
|
|
246
|
+
label: a.label,
|
|
247
|
+
connected: hasToken(a.id),
|
|
248
|
+
})),
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// `busy` is the account being switched to, while its usage is still loading
|
|
253
|
+
function sendAccounts(busy = null) {
|
|
254
|
+
if (win && !win.isDestroyed()) win.webContents.send('accounts', { ...accountsPayload(), busy })
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// the numbers on screen belong to the old account: drop them before the new
|
|
258
|
+
// account's first poll lands, so nothing stale is ever attributed to it
|
|
259
|
+
function switchAccount(id) {
|
|
260
|
+
const leaving = accounts.activeId()
|
|
261
|
+
if (id === leaving) return
|
|
262
|
+
const acc = accounts.setActive(id)
|
|
263
|
+
if (!acc) return
|
|
264
|
+
saveAlertState() // the account we're leaving keeps what it had armed
|
|
265
|
+
pruneEmpty(leaving)
|
|
266
|
+
applyAccount(acc)
|
|
267
|
+
clearTimeout(usageTimer)
|
|
268
|
+
usageBackoff = 5 * 60 * 1000
|
|
269
|
+
pushRealUsage(null)
|
|
270
|
+
if (win && !win.isDestroyed()) {
|
|
271
|
+
win.webContents.send('auth-state', { connected: auth.isConnected() })
|
|
272
|
+
win.webContents.send('profile', null)
|
|
273
|
+
}
|
|
274
|
+
sendAccounts(id) // the row goes pending right away…
|
|
275
|
+
|
|
276
|
+
// …because the rest is synchronous and slow: re-reading the new account's
|
|
277
|
+
// logs walks every .jsonl under its projects dir, which blocks this process
|
|
278
|
+
// for a beat on a busy machine. Yielding first lets the renderer paint the
|
|
279
|
+
// pending state instead of freezing mid-click.
|
|
280
|
+
setTimeout(() => {
|
|
281
|
+
if (doTick) doTick()
|
|
282
|
+
startUsagePoll()
|
|
283
|
+
sendProfile()
|
|
284
|
+
updateTray()
|
|
285
|
+
sendAccounts()
|
|
286
|
+
}, 0)
|
|
131
287
|
}
|
|
132
288
|
|
|
289
|
+
// a slot only earns its place by holding a login. One that was never connected
|
|
290
|
+
// — the browser flow abandoned, or "add" clicked twice — is dropped the moment
|
|
291
|
+
// we leave it, so the list can't fill up with "Not connected yet".
|
|
292
|
+
// asked of any account, not just the active one — auth only knows about the
|
|
293
|
+
// dir it is currently pointed at
|
|
294
|
+
function hasToken(id) {
|
|
295
|
+
return fs.existsSync(path.join(accounts.dataDirOf(id), 'auth.json'))
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function pruneEmpty(id) {
|
|
299
|
+
if (id === accounts.DEFAULT_ID || id === accounts.activeId()) return
|
|
300
|
+
const acc = accounts.list().find((a) => a.id === id)
|
|
301
|
+
if (!acc || acc.label || hasToken(id)) return
|
|
302
|
+
accounts.remove(id)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
ipcMain.on('accounts-switch', (_e, id) => switchAccount(String(id)))
|
|
306
|
+
// "add an account" *is* "log in": the browser opens on the new, empty slot, so
|
|
307
|
+
// the token lands in it and not in the account we just left
|
|
308
|
+
ipcMain.on('accounts-add', () => {
|
|
309
|
+
switchAccount(accounts.add())
|
|
310
|
+
startLogin()
|
|
311
|
+
})
|
|
312
|
+
ipcMain.on('accounts-remove', (_e, id) => {
|
|
313
|
+
if (accounts.remove(String(id))) sendAccounts()
|
|
314
|
+
})
|
|
315
|
+
|
|
133
316
|
function createWindow() {
|
|
134
317
|
config = loadConfig()
|
|
318
|
+
// a login abandoned in an earlier run: nothing stays pending across a restart,
|
|
319
|
+
// so fall back to the first account and let the empty slots go
|
|
320
|
+
const boot = accounts.active()
|
|
321
|
+
if (boot.id !== accounts.DEFAULT_ID && !boot.label && !hasToken(boot.id))
|
|
322
|
+
accounts.setActive(accounts.DEFAULT_ID)
|
|
323
|
+
for (const a of accounts.list()) pruneEmpty(a.id)
|
|
324
|
+
applyAccount(accounts.active())
|
|
135
325
|
const { workAreaSize } = screen.getPrimaryDisplay()
|
|
136
326
|
const H = 480
|
|
137
327
|
|
|
@@ -207,6 +397,7 @@ function createWindow() {
|
|
|
207
397
|
win.webContents.send('config', publicConfig(config))
|
|
208
398
|
win.webContents.send('version', app.getVersion())
|
|
209
399
|
win.webContents.send('auth-state', { connected: auth.isConnected() })
|
|
400
|
+
sendAccounts()
|
|
210
401
|
pollTimer = setInterval(tick, config.pollIntervalMs)
|
|
211
402
|
startUsagePoll()
|
|
212
403
|
sendProfile()
|
|
@@ -265,9 +456,28 @@ function destroyTray() {
|
|
|
265
456
|
}
|
|
266
457
|
}
|
|
267
458
|
|
|
459
|
+
// the accounts entry only earns its place once there's more than one
|
|
460
|
+
function accountsMenuItems() {
|
|
461
|
+
const list = accounts.list()
|
|
462
|
+
if (list.length < 2) return []
|
|
463
|
+
const activeId = accounts.activeId()
|
|
464
|
+
return [
|
|
465
|
+
{
|
|
466
|
+
label: 'Account',
|
|
467
|
+
submenu: list.map((a) => ({
|
|
468
|
+
label: a.label || 'Not connected',
|
|
469
|
+
type: 'radio',
|
|
470
|
+
checked: a.id === activeId,
|
|
471
|
+
click: () => switchAccount(a.id),
|
|
472
|
+
})),
|
|
473
|
+
},
|
|
474
|
+
]
|
|
475
|
+
}
|
|
476
|
+
|
|
268
477
|
function trayMenu() {
|
|
269
478
|
return Menu.buildFromTemplate([
|
|
270
479
|
{ label: 'Open Clauddy', click: () => showPopover() },
|
|
480
|
+
...accountsMenuItems(),
|
|
271
481
|
{
|
|
272
482
|
label: 'Open Usage page',
|
|
273
483
|
click: () => shell.openExternal('https://claude.ai/settings/usage'),
|
|
@@ -289,6 +499,16 @@ function showPopover() {
|
|
|
289
499
|
win.focus()
|
|
290
500
|
}
|
|
291
501
|
|
|
502
|
+
// bring the widget to the front from wherever it is, in either mode
|
|
503
|
+
function showWidget() {
|
|
504
|
+
if (!win || win.isDestroyed()) return
|
|
505
|
+
if (currentMode === 'menubar') showPopover()
|
|
506
|
+
else {
|
|
507
|
+
win.show()
|
|
508
|
+
win.focus()
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
292
512
|
// route every programmatic move through here so the 'moved' handler can tell
|
|
293
513
|
// our own repositioning apart from a genuine user drag (and skip saving it)
|
|
294
514
|
function moveWindow(x, y) {
|
|
@@ -369,6 +589,7 @@ function updateTray() {
|
|
|
369
589
|
|
|
370
590
|
// send real usage to the renderer and refresh the tray title in one place
|
|
371
591
|
function pushRealUsage(u) {
|
|
592
|
+
realUsage = u
|
|
372
593
|
sessionPct = u?.session ? u.session.pct : null
|
|
373
594
|
updateTray()
|
|
374
595
|
checkScopedAlerts(config, u)
|
|
@@ -428,6 +649,8 @@ async function pollUsage() {
|
|
|
428
649
|
} else if (e && e.status === 401) {
|
|
429
650
|
auth.clear()
|
|
430
651
|
pushRealUsage(null)
|
|
652
|
+
sendAccounts()
|
|
653
|
+
alertAuthLost(config)
|
|
431
654
|
if (win && !win.isDestroyed()) {
|
|
432
655
|
win.webContents.send('auth-state', { connected: false })
|
|
433
656
|
win.webContents.send('profile', null)
|
|
@@ -445,17 +668,28 @@ async function sendProfile() {
|
|
|
445
668
|
if (!auth.isConnected()) return
|
|
446
669
|
try {
|
|
447
670
|
const p = await auth.fetchProfile()
|
|
671
|
+
if (p?.email) {
|
|
672
|
+
accounts.label(accounts.activeId(), p.email) // a better name than "acct-xyz"
|
|
673
|
+
sendAccounts()
|
|
674
|
+
}
|
|
448
675
|
if (win && !win.isDestroyed()) win.webContents.send('profile', p)
|
|
449
676
|
} catch {} // non-fatal: the chip just stays hidden
|
|
450
677
|
}
|
|
451
678
|
|
|
452
|
-
|
|
679
|
+
// begin() has to run *after* any account switch: switching resets the pending
|
|
680
|
+
// PKCE verifier, which would strand a login started before it
|
|
681
|
+
function startLogin() {
|
|
682
|
+
shell.openExternal(auth.begin())
|
|
683
|
+
if (win && !win.isDestroyed()) win.webContents.send('auth-pending')
|
|
684
|
+
}
|
|
685
|
+
ipcMain.on('auth-start', startLogin)
|
|
453
686
|
ipcMain.on('auth-code', async (_e, code) => {
|
|
454
687
|
const ok = () => {
|
|
455
688
|
if (win && !win.isDestroyed()) {
|
|
456
689
|
win.webContents.send('auth-state', { connected: true })
|
|
457
690
|
win.webContents.send('auth-result', { ok: true })
|
|
458
691
|
}
|
|
692
|
+
sendAccounts()
|
|
459
693
|
sendProfile()
|
|
460
694
|
}
|
|
461
695
|
try {
|
|
@@ -484,6 +718,7 @@ ipcMain.on('auth-logout', () => {
|
|
|
484
718
|
auth.clear()
|
|
485
719
|
clearTimeout(usageTimer)
|
|
486
720
|
pushRealUsage(null)
|
|
721
|
+
sendAccounts()
|
|
487
722
|
if (win && !win.isDestroyed()) {
|
|
488
723
|
win.webContents.send('auth-state', { connected: false })
|
|
489
724
|
win.webContents.send('profile', null)
|
|
@@ -503,8 +738,14 @@ ipcMain.on('save-config', (_e, patch) => {
|
|
|
503
738
|
fs.mkdirSync(path.dirname(EXTERNAL_CONFIG), { recursive: true })
|
|
504
739
|
fs.writeFileSync(EXTERNAL_CONFIG, JSON.stringify(obj, null, 2))
|
|
505
740
|
} catch {}
|
|
741
|
+
const prevThresholds = String(config.alertThresholds)
|
|
506
742
|
config = loadConfig()
|
|
507
|
-
|
|
743
|
+
// re-arm only when the thresholds actually moved: saving an unrelated setting
|
|
744
|
+
// (zoom, mode…) shouldn't replay an alert the user already dismissed
|
|
745
|
+
if (String(config.alertThresholds) !== prevThresholds) {
|
|
746
|
+
armed.clear()
|
|
747
|
+
saveAlertState()
|
|
748
|
+
}
|
|
508
749
|
if (doTick) doTick()
|
|
509
750
|
if (win && !win.isDestroyed()) win.webContents.send('config', publicConfig(config))
|
|
510
751
|
applyMode(config.mode) // switch between floating widget and menu-bar popover live
|
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.19.0",
|
|
5
5
|
"description": "A cute desktop pet that tracks your Claude Code usage",
|
|
6
6
|
"main": "main.js",
|
|
7
7
|
"bin": {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"preload.js",
|
|
14
14
|
"usage.js",
|
|
15
15
|
"auth.js",
|
|
16
|
+
"accounts.js",
|
|
16
17
|
"config.json",
|
|
17
18
|
"renderer"
|
|
18
19
|
],
|
|
@@ -71,6 +72,7 @@
|
|
|
71
72
|
"preload.js",
|
|
72
73
|
"usage.js",
|
|
73
74
|
"auth.js",
|
|
75
|
+
"accounts.js",
|
|
74
76
|
"config.json",
|
|
75
77
|
"renderer/**",
|
|
76
78
|
"build/trayTemplate.png",
|
package/preload.js
CHANGED
|
@@ -10,10 +10,15 @@ contextBridge.exposeInMainWorld('api', {
|
|
|
10
10
|
onRealUsage: (cb) => ipcRenderer.on('real-usage', (_e, u) => cb(u)),
|
|
11
11
|
onAuthState: (cb) => ipcRenderer.on('auth-state', (_e, s) => cb(s)),
|
|
12
12
|
onProfile: (cb) => ipcRenderer.on('profile', (_e, p) => cb(p)),
|
|
13
|
+
onAuthPending: (cb) => ipcRenderer.on('auth-pending', () => cb()),
|
|
13
14
|
onAuthResult: (cb) => ipcRenderer.on('auth-result', (_e, r) => cb(r)),
|
|
14
15
|
authStart: () => ipcRenderer.send('auth-start'),
|
|
15
16
|
authCode: (code) => ipcRenderer.send('auth-code', code),
|
|
16
17
|
authLogout: () => ipcRenderer.send('auth-logout'),
|
|
18
|
+
onAccounts: (cb) => ipcRenderer.on('accounts', (_e, a) => cb(a)),
|
|
19
|
+
accountSwitch: (id) => ipcRenderer.send('accounts-switch', id),
|
|
20
|
+
accountAdd: () => ipcRenderer.send('accounts-add'),
|
|
21
|
+
accountRemove: (id) => ipcRenderer.send('accounts-remove', id),
|
|
17
22
|
onDebugState: (cb) => ipcRenderer.on('debug-state', (_e, s) => cb(s)),
|
|
18
23
|
onVersion: (cb) => ipcRenderer.on('version', (_e, v) => cb(v)),
|
|
19
24
|
onUpdateStatus: (cb) => ipcRenderer.on('update-status', (_e, s) => cb(s)),
|
package/renderer/index.html
CHANGED
|
@@ -305,8 +305,22 @@
|
|
|
305
305
|
<!-- settings panel -->
|
|
306
306
|
<div id="settings">
|
|
307
307
|
<div id="account">
|
|
308
|
+
<!-- who you are comes first: the list, then the login for whichever
|
|
309
|
+
account is active — so "add" and the code field sit together -->
|
|
310
|
+
<div id="acc-connected">
|
|
311
|
+
<span class="acc-ok" id="acc-ok">● Connected</span>
|
|
312
|
+
<button id="acc-logout" class="acc-link">Disconnect</button>
|
|
313
|
+
</div>
|
|
314
|
+
<!-- one row per subscription; only the active one is polled -->
|
|
315
|
+
<div id="acc-switch" hidden>
|
|
316
|
+
<div class="set-hint set-hint-left">Accounts</div>
|
|
317
|
+
<div id="acc-list"></div>
|
|
318
|
+
</div>
|
|
319
|
+
<button id="acc-add" class="acc-link">+ Add another account</button>
|
|
308
320
|
<div id="acc-disconnected">
|
|
309
|
-
<div class="set-hint set-hint-left"
|
|
321
|
+
<div class="set-hint set-hint-left" id="acc-intro">
|
|
322
|
+
Connect your Claude account to show your real usage.
|
|
323
|
+
</div>
|
|
310
324
|
<button id="acc-connect" class="acc-btn">Log in with browser</button>
|
|
311
325
|
<div id="acc-paste">
|
|
312
326
|
<div class="set-hint set-hint-left">Paste the code from the browser page:</div>
|
|
@@ -315,10 +329,6 @@
|
|
|
315
329
|
<div id="acc-msg"></div>
|
|
316
330
|
</div>
|
|
317
331
|
</div>
|
|
318
|
-
<div id="acc-connected">
|
|
319
|
-
<span class="acc-ok" id="acc-ok">● Connected</span>
|
|
320
|
-
<button id="acc-logout" class="acc-link">Disconnect</button>
|
|
321
|
-
</div>
|
|
322
332
|
</div>
|
|
323
333
|
|
|
324
334
|
<div id="set-rows">
|
package/renderer/pet.js
CHANGED
|
@@ -682,7 +682,9 @@ window.api.onAuthState((s) => {
|
|
|
682
682
|
if (!on) {
|
|
683
683
|
realUsage = null
|
|
684
684
|
document.body.classList.remove('live')
|
|
685
|
-
|
|
685
|
+
// main sends this before it opens the browser for a new login, so the
|
|
686
|
+
// pending state that comes next is not undone by it
|
|
687
|
+
endLogin()
|
|
686
688
|
showProfile(null)
|
|
687
689
|
}
|
|
688
690
|
if (document.body.classList.contains('settings-open')) fitSize()
|
|
@@ -720,12 +722,94 @@ function setPlan(node, plan) {
|
|
|
720
722
|
}
|
|
721
723
|
}
|
|
722
724
|
window.api.onProfile(showProfile)
|
|
725
|
+
|
|
726
|
+
// ---- accounts ---------------------------------------------------------------
|
|
727
|
+
// The list is only worth showing once there are two; with a single account the
|
|
728
|
+
// panel keeps the shape it has always had, plus the "add" link.
|
|
729
|
+
let pendingRemove = null // id whose × is armed, so removal takes two clicks
|
|
730
|
+
let lastAccounts = null
|
|
731
|
+
|
|
732
|
+
function renderAccounts(a) {
|
|
733
|
+
lastAccounts = a
|
|
734
|
+
const list = a?.accounts || []
|
|
735
|
+
const box = el('acc-list')
|
|
736
|
+
el('acc-switch').hidden = list.length < 2
|
|
737
|
+
box.textContent = ''
|
|
738
|
+
box.classList.toggle('busy', !!a?.busy)
|
|
739
|
+
for (const acc of list) {
|
|
740
|
+
const active = acc.id === a.active
|
|
741
|
+
const row = document.createElement('div')
|
|
742
|
+
row.className = 'acc-item'
|
|
743
|
+
if (active) row.classList.add('active')
|
|
744
|
+
if (acc.connected) row.classList.add('connected')
|
|
745
|
+
// the account being switched to owns the spinner: everything on screen
|
|
746
|
+
// still belongs to the one we're leaving
|
|
747
|
+
if (a.busy === acc.id) row.classList.add('loading')
|
|
748
|
+
|
|
749
|
+
const dot = document.createElement('span')
|
|
750
|
+
dot.className = 'dot'
|
|
751
|
+
const who = document.createElement('span')
|
|
752
|
+
who.className = 'who'
|
|
753
|
+
who.textContent =
|
|
754
|
+
acc.label || (acc.connected ? 'Connected' : active ? 'Waiting for login…' : 'Not connected')
|
|
755
|
+
row.append(dot, who)
|
|
756
|
+
|
|
757
|
+
// the first account holds the original install's data, and the active one
|
|
758
|
+
// is what the widget is showing — neither can be removed from under you
|
|
759
|
+
if (acc.id !== 'default' && !active) {
|
|
760
|
+
const x = document.createElement('button')
|
|
761
|
+
const armed = pendingRemove === acc.id
|
|
762
|
+
x.className = armed ? 'drop armed' : 'drop'
|
|
763
|
+
x.textContent = armed ? 'remove?' : '\u00d7'
|
|
764
|
+
x.title = 'Remove this account'
|
|
765
|
+
x.addEventListener('click', (e) => {
|
|
766
|
+
e.stopPropagation() // the row itself switches accounts
|
|
767
|
+
if (armed) {
|
|
768
|
+
pendingRemove = null
|
|
769
|
+
window.api.accountRemove(acc.id)
|
|
770
|
+
} else {
|
|
771
|
+
pendingRemove = acc.id // a click deletes a token: ask once
|
|
772
|
+
renderAccounts(a)
|
|
773
|
+
}
|
|
774
|
+
})
|
|
775
|
+
row.appendChild(x)
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
if (!active) row.addEventListener('click', () => switchAccount(acc.id))
|
|
779
|
+
box.appendChild(row)
|
|
780
|
+
}
|
|
781
|
+
fitSize()
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// paint the pending state from the click itself rather than waiting for main to
|
|
785
|
+
// answer — the answer is exactly what takes a moment
|
|
786
|
+
function switchAccount(id) {
|
|
787
|
+
if (lastAccounts?.busy) return
|
|
788
|
+
endLogin() // the code from the account we're leaving is no good here
|
|
789
|
+
pendingRemove = null
|
|
790
|
+
renderAccounts({ ...lastAccounts, busy: id })
|
|
791
|
+
window.api.accountSwitch(id)
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
window.api.onAccounts((a) => {
|
|
795
|
+
pendingRemove = null
|
|
796
|
+
renderAccounts(a)
|
|
797
|
+
})
|
|
798
|
+
|
|
799
|
+
function endLogin() {
|
|
800
|
+
document.body.classList.remove('awaiting')
|
|
801
|
+
el('acc-paste').classList.remove('show')
|
|
802
|
+
el('acc-code').value = ''
|
|
803
|
+
el('acc-msg').textContent = ''
|
|
804
|
+
}
|
|
805
|
+
el('acc-add').addEventListener('click', () => {
|
|
806
|
+
el('acc-msg').textContent = ''
|
|
807
|
+
window.api.accountAdd() // switches to a fresh slot and opens the browser
|
|
808
|
+
})
|
|
723
809
|
let successTimer = null
|
|
724
810
|
window.api.onAuthResult((r) => {
|
|
725
811
|
if (r?.ok) {
|
|
726
|
-
|
|
727
|
-
el('acc-code').value = ''
|
|
728
|
-
el('acc-paste').classList.remove('show')
|
|
812
|
+
endLogin()
|
|
729
813
|
// logging in is done: land back on the home panel, where the fresh live
|
|
730
814
|
// meters show up under a short-lived cheer — and the pet throws confetti
|
|
731
815
|
document.body.classList.remove('settings-open')
|
|
@@ -738,6 +822,8 @@ window.api.onAuthResult((r) => {
|
|
|
738
822
|
}, 6000)
|
|
739
823
|
celebrate()
|
|
740
824
|
} else {
|
|
825
|
+
// failed: put "Log in with browser" back, so a retry is one click away
|
|
826
|
+
document.body.classList.remove('awaiting')
|
|
741
827
|
const e = r?.error || ''
|
|
742
828
|
el('acc-msg').textContent = /429|rate_limit/i.test(e)
|
|
743
829
|
? 'Rate limited by Anthropic — wait a few minutes, then try once with a fresh code.'
|
|
@@ -794,9 +880,13 @@ el('close').addEventListener('click', () => window.api.quit())
|
|
|
794
880
|
el('usage').addEventListener('click', () => window.api.openUsage())
|
|
795
881
|
|
|
796
882
|
// account login (browser flow)
|
|
797
|
-
el('acc-connect').addEventListener('click', () =>
|
|
798
|
-
|
|
799
|
-
|
|
883
|
+
el('acc-connect').addEventListener('click', () => window.api.authStart())
|
|
884
|
+
// main opened the browser — the only thing left to do is paste the code back,
|
|
885
|
+
// so the panel narrows to exactly that
|
|
886
|
+
window.api.onAuthPending(() => {
|
|
887
|
+
document.body.classList.add('awaiting')
|
|
888
|
+
el('acc-paste').classList.add('show')
|
|
889
|
+
el('acc-code').focus()
|
|
800
890
|
fitSize()
|
|
801
891
|
})
|
|
802
892
|
// the Connect button only lights up once there's a code to submit
|
|
@@ -1025,6 +1115,7 @@ if (typeof module === 'object' && module.exports) {
|
|
|
1025
1115
|
renderProjects,
|
|
1026
1116
|
renderHeat,
|
|
1027
1117
|
showProfile,
|
|
1118
|
+
renderAccounts,
|
|
1028
1119
|
burn,
|
|
1029
1120
|
}
|
|
1030
1121
|
}
|
package/renderer/style.css
CHANGED
|
@@ -683,6 +683,109 @@ body.live .live-only {
|
|
|
683
683
|
gap: 10px; /* the email ellipsizes instead of touching Disconnect */
|
|
684
684
|
flex-wrap: wrap; /* the success line drops to its own row */
|
|
685
685
|
}
|
|
686
|
+
#acc-switch {
|
|
687
|
+
margin-top: 10px;
|
|
688
|
+
}
|
|
689
|
+
#acc-switch[hidden] {
|
|
690
|
+
display: none;
|
|
691
|
+
}
|
|
692
|
+
#acc-list {
|
|
693
|
+
display: flex;
|
|
694
|
+
flex-direction: column;
|
|
695
|
+
margin-top: 2px;
|
|
696
|
+
}
|
|
697
|
+
#acc-list.busy {
|
|
698
|
+
pointer-events: none; /* one switch at a time */
|
|
699
|
+
}
|
|
700
|
+
/* a plain list, not a stack of cards: the panel already has enough boxes */
|
|
701
|
+
.acc-item {
|
|
702
|
+
display: flex;
|
|
703
|
+
align-items: center;
|
|
704
|
+
gap: 7px;
|
|
705
|
+
padding: 5px 6px;
|
|
706
|
+
border-radius: 6px;
|
|
707
|
+
cursor: pointer;
|
|
708
|
+
color: var(--muted);
|
|
709
|
+
transition:
|
|
710
|
+
background 0.15s,
|
|
711
|
+
color 0.15s;
|
|
712
|
+
}
|
|
713
|
+
.acc-item:hover {
|
|
714
|
+
background: rgba(255, 255, 255, 0.06);
|
|
715
|
+
color: var(--text);
|
|
716
|
+
}
|
|
717
|
+
.acc-item.active {
|
|
718
|
+
background: rgba(217, 119, 87, 0.14);
|
|
719
|
+
color: var(--text);
|
|
720
|
+
cursor: default;
|
|
721
|
+
}
|
|
722
|
+
.acc-item .dot {
|
|
723
|
+
width: 5px;
|
|
724
|
+
height: 5px;
|
|
725
|
+
border-radius: 50%;
|
|
726
|
+
background: rgba(255, 255, 255, 0.22); /* logged out: a row with no usage yet */
|
|
727
|
+
flex: none;
|
|
728
|
+
}
|
|
729
|
+
.acc-item.connected .dot {
|
|
730
|
+
background: var(--green);
|
|
731
|
+
}
|
|
732
|
+
.acc-item .who {
|
|
733
|
+
flex: 1;
|
|
734
|
+
min-width: 0;
|
|
735
|
+
font-size: 11px;
|
|
736
|
+
overflow: hidden;
|
|
737
|
+
text-overflow: ellipsis;
|
|
738
|
+
white-space: nowrap;
|
|
739
|
+
}
|
|
740
|
+
/* switching re-reads that account's logs, which takes a beat — say so */
|
|
741
|
+
.acc-item.loading .dot {
|
|
742
|
+
background: var(--coral);
|
|
743
|
+
animation: acc-pulse 0.9s ease-in-out infinite;
|
|
744
|
+
}
|
|
745
|
+
.acc-item.loading .who {
|
|
746
|
+
opacity: 0.6;
|
|
747
|
+
}
|
|
748
|
+
@keyframes acc-pulse {
|
|
749
|
+
0%,
|
|
750
|
+
100% {
|
|
751
|
+
opacity: 1;
|
|
752
|
+
}
|
|
753
|
+
50% {
|
|
754
|
+
opacity: 0.2;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
.acc-item .drop {
|
|
758
|
+
border: none;
|
|
759
|
+
background: none;
|
|
760
|
+
color: var(--muted);
|
|
761
|
+
font-size: 12px;
|
|
762
|
+
line-height: 1;
|
|
763
|
+
padding: 0 2px;
|
|
764
|
+
cursor: pointer;
|
|
765
|
+
opacity: 0; /* one row at a time, on hover: this deletes a token */
|
|
766
|
+
transition: opacity 0.15s;
|
|
767
|
+
}
|
|
768
|
+
.acc-item:hover .drop {
|
|
769
|
+
opacity: 0.6;
|
|
770
|
+
}
|
|
771
|
+
.acc-item .drop:hover {
|
|
772
|
+
opacity: 1;
|
|
773
|
+
color: var(--text);
|
|
774
|
+
}
|
|
775
|
+
/* armed: the confirm has to be legible whether or not the cursor is on the row */
|
|
776
|
+
.acc-item .drop.armed,
|
|
777
|
+
.acc-item:hover .drop.armed {
|
|
778
|
+
opacity: 1;
|
|
779
|
+
font-size: 10px;
|
|
780
|
+
color: var(--coral);
|
|
781
|
+
}
|
|
782
|
+
#acc-add {
|
|
783
|
+
margin-top: 6px;
|
|
784
|
+
}
|
|
785
|
+
/* while a login is pending, "add another" would start a second one */
|
|
786
|
+
body.awaiting #acc-add {
|
|
787
|
+
display: none;
|
|
788
|
+
}
|
|
686
789
|
#acc-paste {
|
|
687
790
|
display: none;
|
|
688
791
|
margin-top: 8px;
|
|
@@ -712,6 +815,15 @@ body.auth-on #acc-disconnected {
|
|
|
712
815
|
body.auth-on #acc-connected {
|
|
713
816
|
display: flex;
|
|
714
817
|
}
|
|
818
|
+
/* the browser is already open on the login page: the button that opens it and
|
|
819
|
+
the pitch for connecting are both noise now — only the code field matters */
|
|
820
|
+
body.awaiting #acc-connect,
|
|
821
|
+
body.awaiting #acc-intro {
|
|
822
|
+
display: none;
|
|
823
|
+
}
|
|
824
|
+
#acc-disconnected {
|
|
825
|
+
margin-top: 10px;
|
|
826
|
+
}
|
|
715
827
|
|
|
716
828
|
/* just logged in: a short-lived green cheer at the top of the home panel */
|
|
717
829
|
#home-success {
|
package/usage.js
CHANGED
|
@@ -2,8 +2,26 @@ const fs = require('node:fs')
|
|
|
2
2
|
const path = require('node:path')
|
|
3
3
|
const os = require('node:os')
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
// the Claude config dir whose logs we read. mutable because the widget can
|
|
6
|
+
// switch between accounts at runtime, each one pointing at its own dir.
|
|
7
|
+
const DEFAULT_CLAUDE_DIR = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
|
8
|
+
let claudeDir = DEFAULT_CLAUDE_DIR
|
|
9
|
+
let projectsDir = path.join(claudeDir, 'projects')
|
|
10
|
+
|
|
11
|
+
// point the reader at another account's logs. fileCache survives: it is keyed
|
|
12
|
+
// by absolute path, so entries from another dir can't collide — and keeping it
|
|
13
|
+
// is what makes switching back to an account instant instead of a full re-scan.
|
|
14
|
+
// projectLabelCache is keyed by a directory *name*, which can repeat across
|
|
15
|
+
// dirs, so that one goes.
|
|
16
|
+
function setClaudeDir(dir) {
|
|
17
|
+
// an account with no dir of its own reads the default one — which is still
|
|
18
|
+
// CLAUDE_CONFIG_DIR when the whole instance is isolated that way
|
|
19
|
+
const next = dir || DEFAULT_CLAUDE_DIR
|
|
20
|
+
if (next === claudeDir) return
|
|
21
|
+
claudeDir = next
|
|
22
|
+
projectsDir = path.join(claudeDir, 'projects')
|
|
23
|
+
projectLabelCache.clear()
|
|
24
|
+
}
|
|
7
25
|
|
|
8
26
|
function labelFor(model) {
|
|
9
27
|
if (!model) return 'desconhecido'
|
|
@@ -103,7 +121,7 @@ function projectLabel(dirName) {
|
|
|
103
121
|
}
|
|
104
122
|
|
|
105
123
|
function projectDirOf(file) {
|
|
106
|
-
const rel = path.relative(
|
|
124
|
+
const rel = path.relative(projectsDir, file)
|
|
107
125
|
const first = rel.split(path.sep)[0]
|
|
108
126
|
return first && first !== '..' ? first : null
|
|
109
127
|
}
|
|
@@ -279,7 +297,7 @@ function getUsage(config) {
|
|
|
279
297
|
const scanCutoff = start30 - dayMs
|
|
280
298
|
|
|
281
299
|
const files = []
|
|
282
|
-
walkJsonl(
|
|
300
|
+
walkJsonl(projectsDir, files, scanCutoff)
|
|
283
301
|
|
|
284
302
|
let lastMtime = 0
|
|
285
303
|
let newestFile = null
|
|
@@ -397,4 +415,12 @@ function getUsage(config) {
|
|
|
397
415
|
// labelFor/tokensOf/detectActivity are exported for the tests — they're the
|
|
398
416
|
// parts that decode Claude Code's log format, which is the thing most likely
|
|
399
417
|
// to change out from under us.
|
|
400
|
-
module.exports = {
|
|
418
|
+
module.exports = {
|
|
419
|
+
getUsage,
|
|
420
|
+
setClaudeDir,
|
|
421
|
+
labelFor,
|
|
422
|
+
projectLabel,
|
|
423
|
+
tokensOf,
|
|
424
|
+
detectActivity,
|
|
425
|
+
PLAN_BUDGETS,
|
|
426
|
+
}
|