clauddy 1.18.0 → 1.20.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 +13 -0
- package/accounts.js +154 -0
- package/auth.js +17 -6
- package/main.js +156 -7
- package/package.json +3 -1
- package/preload.js +6 -0
- package/renderer/index.html +16 -7
- package/renderer/pet.js +179 -9
- package/renderer/style.css +213 -2
- 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? The **account chip** in the top-left corner is the switcher: click it for the list of accounts, with the active one marked, plus **"+ Add another account"** — which opens the same browser login and drops the token it brings back into a new slot. The tray icon has the same list under its **Account** submenu. 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 (the **×** on its row) deletes its token from disk. The one you're currently on can't be removed — switch away first — and neither can the last one left. Removing the first account clears its token without touching the settings that live in the same folder.
|
|
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:
|
|
@@ -242,7 +252,10 @@ Everything lives on your machine, in `~/.claude-usage-monitor/`:
|
|
|
242
252
|
|
|
243
253
|
- `auth.json` — your OAuth token (file mode `600`, never committed)
|
|
244
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
|
|
245
257
|
- `debug.json` — scratch file for the `./pet` simulator
|
|
258
|
+
- `accounts/<id>/` — the same `auth.json` + `alerts.json`, for each extra account
|
|
246
259
|
|
|
247
260
|
Nothing leaves your machine except the OAuth calls to Anthropic's own login and usage endpoints.
|
|
248
261
|
|
package/accounts.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
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
|
+
// the default account can be removed like any other, so it is only recreated
|
|
32
|
+
// when nothing is left — an empty list would leave the widget with no token
|
|
33
|
+
if (!list.length) list.push(defaults().accounts[0])
|
|
34
|
+
const active = list.some((a) => a.id === j?.active) ? j.active : list[0].id
|
|
35
|
+
return { active, accounts: list }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// re-read on every call instead of caching: the file is a handful of rows, it
|
|
39
|
+
// is read only when the account list is shown or changed, and a stale cache
|
|
40
|
+
// here would mean the widget polling a token it thinks it still has
|
|
41
|
+
function load() {
|
|
42
|
+
try {
|
|
43
|
+
return sane(JSON.parse(fs.readFileSync(FILE, 'utf8')))
|
|
44
|
+
} catch {
|
|
45
|
+
return defaults() // first run, or a corrupted file
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function save(state) {
|
|
50
|
+
try {
|
|
51
|
+
fs.mkdirSync(BASE_DIR, { recursive: true })
|
|
52
|
+
fs.writeFileSync(FILE, JSON.stringify(state, null, 2))
|
|
53
|
+
} catch {}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// where an account's own files (auth.json, alerts.json) live
|
|
57
|
+
function dataDirOf(id) {
|
|
58
|
+
return id === DEFAULT_ID ? BASE_DIR : path.join(BASE_DIR, 'accounts', id)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function list() {
|
|
62
|
+
return load().accounts.map((a) => ({ ...a }))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function activeId() {
|
|
66
|
+
return load().active
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function active() {
|
|
70
|
+
const s = load()
|
|
71
|
+
return s.accounts.find((a) => a.id === s.active) || s.accounts[0]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function find(s, id) {
|
|
75
|
+
return s.accounts.find((a) => a.id === id)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function setActive(id) {
|
|
79
|
+
const s = load()
|
|
80
|
+
const hit = find(s, id)
|
|
81
|
+
if (!hit) return null
|
|
82
|
+
s.active = id
|
|
83
|
+
save(s)
|
|
84
|
+
return hit
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// a fresh, logged-out account — the caller switches to it and runs the usual
|
|
88
|
+
// browser login, which is what gives it an identity
|
|
89
|
+
function add(claudeDir) {
|
|
90
|
+
const s = load()
|
|
91
|
+
// two clicks inside the same millisecond used to mint the same id, and the
|
|
92
|
+
// duplicate then looked like the account we were already on
|
|
93
|
+
let id = `acct-${Date.now().toString(36)}`
|
|
94
|
+
while (s.accounts.some((a) => a.id === id)) {
|
|
95
|
+
id = `acct-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
|
|
96
|
+
}
|
|
97
|
+
s.accounts.push({ id, label: null, claudeDir: claudeDir || null })
|
|
98
|
+
save(s)
|
|
99
|
+
return id
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// the login flow knows the email; that's a better name than "Account 2"
|
|
103
|
+
function label(id, text) {
|
|
104
|
+
const s = load()
|
|
105
|
+
const a = find(s, id)
|
|
106
|
+
if (!a || a.label === (text || null)) return
|
|
107
|
+
a.label = text || null
|
|
108
|
+
save(s)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function setClaudeDir(id, dir) {
|
|
112
|
+
const s = load()
|
|
113
|
+
const a = find(s, id)
|
|
114
|
+
if (!a) return
|
|
115
|
+
a.claudeDir = dir || null
|
|
116
|
+
save(s)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// removing an account takes its token with it — leaving a stray auth.json
|
|
120
|
+
// behind would be a credential nobody can see or revoke from the UI
|
|
121
|
+
function remove(id) {
|
|
122
|
+
const s = load()
|
|
123
|
+
if (id === s.active) return false // switch away first: the widget is showing it
|
|
124
|
+
const i = s.accounts.findIndex((a) => a.id === id)
|
|
125
|
+
if (i < 0) return false
|
|
126
|
+
if (s.accounts.length < 2) return false // never leave the widget with no account
|
|
127
|
+
s.accounts.splice(i, 1)
|
|
128
|
+
save(s)
|
|
129
|
+
try {
|
|
130
|
+
if (id === DEFAULT_ID) {
|
|
131
|
+
// its folder is the app's own data dir: drop the credentials, not the
|
|
132
|
+
// settings, the account list or the simulator file that live beside them
|
|
133
|
+
for (const f of ['auth.json', 'alerts.json'])
|
|
134
|
+
fs.rmSync(path.join(dataDirOf(id), f), { force: true })
|
|
135
|
+
} else {
|
|
136
|
+
fs.rmSync(dataDirOf(id), { recursive: true, force: true })
|
|
137
|
+
}
|
|
138
|
+
} catch {}
|
|
139
|
+
return true
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = {
|
|
143
|
+
BASE_DIR,
|
|
144
|
+
DEFAULT_ID,
|
|
145
|
+
dataDirOf,
|
|
146
|
+
list,
|
|
147
|
+
active,
|
|
148
|
+
activeId,
|
|
149
|
+
setActive,
|
|
150
|
+
add,
|
|
151
|
+
label,
|
|
152
|
+
setClaudeDir,
|
|
153
|
+
remove,
|
|
154
|
+
}
|
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
|
|
|
@@ -98,20 +100,22 @@ function loadConfig() {
|
|
|
98
100
|
// `armed` is persisted so relaunching at 85% doesn't repeat the 80% alert you
|
|
99
101
|
// already dismissed; it also carries the last session % so a reset is detectable
|
|
100
102
|
// across restarts.
|
|
101
|
-
|
|
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')
|
|
102
106
|
let armed = new Set()
|
|
103
107
|
let lastSessionPct = null
|
|
104
108
|
function loadAlertState() {
|
|
105
109
|
try {
|
|
106
|
-
const j = JSON.parse(fs.readFileSync(
|
|
110
|
+
const j = JSON.parse(fs.readFileSync(alertsFile, 'utf8'))
|
|
107
111
|
armed = new Set(Array.isArray(j.armed) ? j.armed : [])
|
|
108
112
|
lastSessionPct = typeof j.lastSessionPct === 'number' ? j.lastSessionPct : null
|
|
109
113
|
} catch {} // first run, or a corrupted file: start from a clean slate
|
|
110
114
|
}
|
|
111
115
|
function saveAlertState() {
|
|
112
116
|
try {
|
|
113
|
-
fs.mkdirSync(
|
|
114
|
-
fs.writeFileSync(
|
|
117
|
+
fs.mkdirSync(path.dirname(alertsFile), { recursive: true })
|
|
118
|
+
fs.writeFileSync(alertsFile, JSON.stringify({ armed: [...armed], lastSessionPct }))
|
|
115
119
|
} catch {}
|
|
116
120
|
}
|
|
117
121
|
|
|
@@ -217,9 +221,120 @@ function alertAuthLost(config) {
|
|
|
217
221
|
notify('Clauddy lost access to your usage', 'open Settings to reconnect')
|
|
218
222
|
}
|
|
219
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)
|
|
287
|
+
}
|
|
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 // never auto-drop the original
|
|
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
|
+
// the account an "add" left behind: cancelling the login has to land back on
|
|
309
|
+
// it, or the widget is stuck on an empty slot with no chip to switch from
|
|
310
|
+
let addedFrom = null
|
|
311
|
+
ipcMain.on('accounts-add', () => {
|
|
312
|
+
addedFrom = accounts.activeId()
|
|
313
|
+
switchAccount(accounts.add())
|
|
314
|
+
startLogin()
|
|
315
|
+
})
|
|
316
|
+
ipcMain.on('accounts-cancel-add', () => {
|
|
317
|
+
const from = addedFrom
|
|
318
|
+
addedFrom = null
|
|
319
|
+
if (!from || from === accounts.activeId() || hasToken(accounts.activeId())) return
|
|
320
|
+
switchAccount(from) // the empty slot is pruned on the way out
|
|
321
|
+
})
|
|
322
|
+
ipcMain.on('accounts-remove', (_e, id) => {
|
|
323
|
+
if (accounts.remove(String(id))) sendAccounts()
|
|
324
|
+
})
|
|
325
|
+
|
|
220
326
|
function createWindow() {
|
|
221
327
|
config = loadConfig()
|
|
222
|
-
|
|
328
|
+
// a login abandoned in an earlier run: nothing stays pending across a restart,
|
|
329
|
+
// so fall back to the first account and let the empty slots go
|
|
330
|
+
const boot = accounts.active()
|
|
331
|
+
if (!boot.label && !hasToken(boot.id)) {
|
|
332
|
+
// the default account can be gone by now, so land on whatever comes first
|
|
333
|
+
const fallback = accounts.list().find((a) => a.id !== boot.id)
|
|
334
|
+
if (fallback) accounts.setActive(fallback.id)
|
|
335
|
+
}
|
|
336
|
+
for (const a of accounts.list()) pruneEmpty(a.id)
|
|
337
|
+
applyAccount(accounts.active())
|
|
223
338
|
const { workAreaSize } = screen.getPrimaryDisplay()
|
|
224
339
|
const H = 480
|
|
225
340
|
|
|
@@ -295,6 +410,7 @@ function createWindow() {
|
|
|
295
410
|
win.webContents.send('config', publicConfig(config))
|
|
296
411
|
win.webContents.send('version', app.getVersion())
|
|
297
412
|
win.webContents.send('auth-state', { connected: auth.isConnected() })
|
|
413
|
+
sendAccounts()
|
|
298
414
|
pollTimer = setInterval(tick, config.pollIntervalMs)
|
|
299
415
|
startUsagePoll()
|
|
300
416
|
sendProfile()
|
|
@@ -353,9 +469,28 @@ function destroyTray() {
|
|
|
353
469
|
}
|
|
354
470
|
}
|
|
355
471
|
|
|
472
|
+
// the accounts entry only earns its place once there's more than one
|
|
473
|
+
function accountsMenuItems() {
|
|
474
|
+
const list = accounts.list()
|
|
475
|
+
if (list.length < 2) return []
|
|
476
|
+
const activeId = accounts.activeId()
|
|
477
|
+
return [
|
|
478
|
+
{
|
|
479
|
+
label: 'Account',
|
|
480
|
+
submenu: list.map((a) => ({
|
|
481
|
+
label: a.label || 'Not connected',
|
|
482
|
+
type: 'radio',
|
|
483
|
+
checked: a.id === activeId,
|
|
484
|
+
click: () => switchAccount(a.id),
|
|
485
|
+
})),
|
|
486
|
+
},
|
|
487
|
+
]
|
|
488
|
+
}
|
|
489
|
+
|
|
356
490
|
function trayMenu() {
|
|
357
491
|
return Menu.buildFromTemplate([
|
|
358
492
|
{ label: 'Open Clauddy', click: () => showPopover() },
|
|
493
|
+
...accountsMenuItems(),
|
|
359
494
|
{
|
|
360
495
|
label: 'Open Usage page',
|
|
361
496
|
click: () => shell.openExternal('https://claude.ai/settings/usage'),
|
|
@@ -527,6 +662,7 @@ async function pollUsage() {
|
|
|
527
662
|
} else if (e && e.status === 401) {
|
|
528
663
|
auth.clear()
|
|
529
664
|
pushRealUsage(null)
|
|
665
|
+
sendAccounts()
|
|
530
666
|
alertAuthLost(config)
|
|
531
667
|
if (win && !win.isDestroyed()) {
|
|
532
668
|
win.webContents.send('auth-state', { connected: false })
|
|
@@ -545,17 +681,29 @@ async function sendProfile() {
|
|
|
545
681
|
if (!auth.isConnected()) return
|
|
546
682
|
try {
|
|
547
683
|
const p = await auth.fetchProfile()
|
|
684
|
+
if (p?.email) {
|
|
685
|
+
accounts.label(accounts.activeId(), p.email) // a better name than "acct-xyz"
|
|
686
|
+
sendAccounts()
|
|
687
|
+
}
|
|
548
688
|
if (win && !win.isDestroyed()) win.webContents.send('profile', p)
|
|
549
689
|
} catch {} // non-fatal: the chip just stays hidden
|
|
550
690
|
}
|
|
551
691
|
|
|
552
|
-
|
|
692
|
+
// begin() has to run *after* any account switch: switching resets the pending
|
|
693
|
+
// PKCE verifier, which would strand a login started before it
|
|
694
|
+
function startLogin() {
|
|
695
|
+
shell.openExternal(auth.begin())
|
|
696
|
+
if (win && !win.isDestroyed()) win.webContents.send('auth-pending')
|
|
697
|
+
}
|
|
698
|
+
ipcMain.on('auth-start', startLogin)
|
|
553
699
|
ipcMain.on('auth-code', async (_e, code) => {
|
|
554
700
|
const ok = () => {
|
|
701
|
+
addedFrom = null // the new account is real now: nothing to roll back to
|
|
555
702
|
if (win && !win.isDestroyed()) {
|
|
556
703
|
win.webContents.send('auth-state', { connected: true })
|
|
557
704
|
win.webContents.send('auth-result', { ok: true })
|
|
558
705
|
}
|
|
706
|
+
sendAccounts()
|
|
559
707
|
sendProfile()
|
|
560
708
|
}
|
|
561
709
|
try {
|
|
@@ -584,6 +732,7 @@ ipcMain.on('auth-logout', () => {
|
|
|
584
732
|
auth.clear()
|
|
585
733
|
clearTimeout(usageTimer)
|
|
586
734
|
pushRealUsage(null)
|
|
735
|
+
sendAccounts()
|
|
587
736
|
if (win && !win.isDestroyed()) {
|
|
588
737
|
win.webContents.send('auth-state', { connected: false })
|
|
589
738
|
win.webContents.send('profile', null)
|
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.20.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,16 @@ 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
|
+
accountCancelAdd: () => ipcRenderer.send('accounts-cancel-add'),
|
|
22
|
+
accountRemove: (id) => ipcRenderer.send('accounts-remove', id),
|
|
17
23
|
onDebugState: (cb) => ipcRenderer.on('debug-state', (_e, s) => cb(s)),
|
|
18
24
|
onVersion: (cb) => ipcRenderer.on('version', (_e, v) => cb(v)),
|
|
19
25
|
onUpdateStatus: (cb) => ipcRenderer.on('update-status', (_e, s) => cb(s)),
|
package/renderer/index.html
CHANGED
|
@@ -6,13 +6,18 @@
|
|
|
6
6
|
<title>Clauddy</title>
|
|
7
7
|
<link rel="stylesheet" href="style.css" />
|
|
8
8
|
</head>
|
|
9
|
-
<body class="state-idle">
|
|
9
|
+
<body class="state-idle one-account">
|
|
10
10
|
<div id="card">
|
|
11
|
-
<div id="account-chip" hidden>
|
|
11
|
+
<div id="account-chip" hidden title="Switch account">
|
|
12
12
|
<span class="ac-dot"></span>
|
|
13
13
|
<span id="ac-email"></span>
|
|
14
|
+
<svg class="ac-caret" viewBox="0 0 12 12" aria-hidden="true">
|
|
15
|
+
<path d="M3 5l3 3 3-3" />
|
|
16
|
+
</svg>
|
|
14
17
|
<span id="ac-plan" class="plan-badge" hidden></span>
|
|
15
18
|
</div>
|
|
19
|
+
<div id="acc-backdrop" hidden></div>
|
|
20
|
+
<div id="acc-menu" hidden></div>
|
|
16
21
|
<div id="controls">
|
|
17
22
|
<button id="gear" title="Settings">⚙</button>
|
|
18
23
|
<button id="usage" title="Open Usage page">
|
|
@@ -305,8 +310,16 @@
|
|
|
305
310
|
<!-- settings panel -->
|
|
306
311
|
<div id="settings">
|
|
307
312
|
<div id="account">
|
|
313
|
+
<!-- who you are comes first: the list, then the login for whichever
|
|
314
|
+
account is active — so "add" and the code field sit together -->
|
|
315
|
+
<div id="acc-connected">
|
|
316
|
+
<span class="acc-ok" id="acc-ok">● Connected</span>
|
|
317
|
+
<button id="acc-logout" class="acc-link">Disconnect</button>
|
|
318
|
+
</div>
|
|
308
319
|
<div id="acc-disconnected">
|
|
309
|
-
<div class="set-hint set-hint-left"
|
|
320
|
+
<div class="set-hint set-hint-left" id="acc-intro">
|
|
321
|
+
Connect your Claude account to show your real usage.
|
|
322
|
+
</div>
|
|
310
323
|
<button id="acc-connect" class="acc-btn">Log in with browser</button>
|
|
311
324
|
<div id="acc-paste">
|
|
312
325
|
<div class="set-hint set-hint-left">Paste the code from the browser page:</div>
|
|
@@ -315,10 +328,6 @@
|
|
|
315
328
|
<div id="acc-msg"></div>
|
|
316
329
|
</div>
|
|
317
330
|
</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
331
|
</div>
|
|
323
332
|
|
|
324
333
|
<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,163 @@ function setPlan(node, plan) {
|
|
|
720
722
|
}
|
|
721
723
|
}
|
|
722
724
|
window.api.onProfile(showProfile)
|
|
725
|
+
|
|
726
|
+
// ---- accounts ---------------------------------------------------------------
|
|
727
|
+
// The account chip doubles as the switcher: click it for the list, with the
|
|
728
|
+
// active one marked. Settings only keeps the login flow itself.
|
|
729
|
+
let pendingRemove = null // id whose × is armed, so removal takes two clicks
|
|
730
|
+
let lastAccounts = null
|
|
731
|
+
let switching = false // a switch is in flight, so the menu waits for its answer
|
|
732
|
+
|
|
733
|
+
// one row of the chip dropdown
|
|
734
|
+
function accountRow(acc, a) {
|
|
735
|
+
const active = acc.id === a.active
|
|
736
|
+
const row = document.createElement('div')
|
|
737
|
+
row.className = 'acc-item'
|
|
738
|
+
if (active) row.classList.add('active')
|
|
739
|
+
if (acc.connected) row.classList.add('connected')
|
|
740
|
+
// the account being switched to owns the spinner: everything on screen
|
|
741
|
+
// still belongs to the one we're leaving
|
|
742
|
+
if (a.busy === acc.id) row.classList.add('loading')
|
|
743
|
+
|
|
744
|
+
const dot = document.createElement('span')
|
|
745
|
+
dot.className = 'dot'
|
|
746
|
+
const who = document.createElement('span')
|
|
747
|
+
who.className = 'who'
|
|
748
|
+
who.textContent =
|
|
749
|
+
acc.label || (acc.connected ? 'Connected' : active ? 'Waiting for login…' : 'Not connected')
|
|
750
|
+
row.append(dot, who)
|
|
751
|
+
return row
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Everything about accounts lives in the chip dropdown: the chip already says
|
|
755
|
+
// which one you are on, so switching, adding and removing all happen there.
|
|
756
|
+
function renderAccounts(a) {
|
|
757
|
+
lastAccounts = a
|
|
758
|
+
document.body.classList.toggle('one-account', (a?.accounts || []).length < 2)
|
|
759
|
+
if (el('acc-menu').hidden) return
|
|
760
|
+
// the switch is done: the chip now names the account the menu was pointing at
|
|
761
|
+
if (switching && !a?.busy) closeAccountMenu()
|
|
762
|
+
else renderAccountMenu()
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function renderAccountMenu() {
|
|
766
|
+
const a = lastAccounts
|
|
767
|
+
const box = el('acc-menu')
|
|
768
|
+
box.textContent = ''
|
|
769
|
+
box.classList.toggle('busy', !!a?.busy)
|
|
770
|
+
for (const acc of a?.accounts || []) {
|
|
771
|
+
const active = acc.id === a.active
|
|
772
|
+
const row = accountRow(acc, a)
|
|
773
|
+
|
|
774
|
+
// the active account is what the widget is showing: it can't be removed
|
|
775
|
+
// from under you, and the last one standing can't be removed at all
|
|
776
|
+
if (!active && (a.accounts || []).length > 1) {
|
|
777
|
+
const x = document.createElement('button')
|
|
778
|
+
const armed = pendingRemove === acc.id
|
|
779
|
+
x.className = armed ? 'drop armed' : 'drop'
|
|
780
|
+
x.textContent = armed ? 'remove?' : '\u00d7'
|
|
781
|
+
x.title = 'Remove this account'
|
|
782
|
+
x.addEventListener('click', (e) => {
|
|
783
|
+
e.stopPropagation() // the row itself switches accounts
|
|
784
|
+
if (armed) {
|
|
785
|
+
pendingRemove = null
|
|
786
|
+
window.api.accountRemove(acc.id)
|
|
787
|
+
} else {
|
|
788
|
+
pendingRemove = acc.id // a click deletes a token: ask once
|
|
789
|
+
renderAccountMenu()
|
|
790
|
+
}
|
|
791
|
+
})
|
|
792
|
+
row.appendChild(x)
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// stopPropagation: re-rendering detaches this row, and the document-level
|
|
796
|
+
// "clicked outside" handler would then read that as a click off the menu
|
|
797
|
+
if (!active)
|
|
798
|
+
row.addEventListener('click', (e) => {
|
|
799
|
+
e.stopPropagation()
|
|
800
|
+
switchAccount(acc.id)
|
|
801
|
+
})
|
|
802
|
+
box.appendChild(row)
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const add = document.createElement('div')
|
|
806
|
+
add.className = 'acc-item acc-add'
|
|
807
|
+
add.textContent = '+ Add another account'
|
|
808
|
+
add.addEventListener('click', () => {
|
|
809
|
+
closeAccountMenu()
|
|
810
|
+
el('acc-msg').textContent = ''
|
|
811
|
+
window.api.accountAdd() // switches to a fresh slot and opens the browser
|
|
812
|
+
})
|
|
813
|
+
box.appendChild(add)
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function closeAccountMenu() {
|
|
817
|
+
el('acc-menu').hidden = true
|
|
818
|
+
el('acc-backdrop').hidden = true
|
|
819
|
+
pendingRemove = null
|
|
820
|
+
switching = false
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function toggleAccountMenu() {
|
|
824
|
+
const box = el('acc-menu')
|
|
825
|
+
if (!box.hidden) {
|
|
826
|
+
closeAccountMenu()
|
|
827
|
+
return
|
|
828
|
+
}
|
|
829
|
+
renderAccountMenu()
|
|
830
|
+
box.hidden = false
|
|
831
|
+
el('acc-backdrop').hidden = false
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
el('account-chip').addEventListener('click', (e) => {
|
|
835
|
+
e.stopPropagation()
|
|
836
|
+
toggleAccountMenu()
|
|
837
|
+
})
|
|
838
|
+
// clicking anywhere else — the pet, the gear, another app — puts it away
|
|
839
|
+
el('acc-backdrop').addEventListener('mousedown', closeAccountMenu)
|
|
840
|
+
document.addEventListener('click', (e) => {
|
|
841
|
+
if (!el('acc-menu').hidden && !el('acc-menu').contains(e.target)) closeAccountMenu()
|
|
842
|
+
})
|
|
843
|
+
window.addEventListener('blur', closeAccountMenu)
|
|
844
|
+
document.addEventListener('keydown', (e) => {
|
|
845
|
+
if (e.key === 'Escape') closeAccountMenu()
|
|
846
|
+
})
|
|
847
|
+
|
|
848
|
+
// paint the pending state from the click itself rather than waiting for main to
|
|
849
|
+
// answer — the answer is exactly what takes a moment
|
|
850
|
+
function switchAccount(id) {
|
|
851
|
+
if (lastAccounts?.busy) return
|
|
852
|
+
endLogin() // the code from the account we're leaving is no good here
|
|
853
|
+
pendingRemove = null
|
|
854
|
+
switching = true // the row stays on screen, pending, until main answers
|
|
855
|
+
renderAccounts({ ...lastAccounts, busy: id })
|
|
856
|
+
window.api.accountSwitch(id)
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
window.api.onAccounts((a) => {
|
|
860
|
+
pendingRemove = null
|
|
861
|
+
renderAccounts(a)
|
|
862
|
+
})
|
|
863
|
+
|
|
864
|
+
// closing the panel while a login is pending gives up on it: main puts us back
|
|
865
|
+
// on the account we were using, and the half-made one goes away
|
|
866
|
+
function abandonLogin() {
|
|
867
|
+
if (!document.body.classList.contains('awaiting')) return
|
|
868
|
+
endLogin()
|
|
869
|
+
window.api.accountCancelAdd()
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function endLogin() {
|
|
873
|
+
document.body.classList.remove('awaiting')
|
|
874
|
+
el('acc-paste').classList.remove('show')
|
|
875
|
+
el('acc-code').value = ''
|
|
876
|
+
el('acc-msg').textContent = ''
|
|
877
|
+
}
|
|
723
878
|
let successTimer = null
|
|
724
879
|
window.api.onAuthResult((r) => {
|
|
725
880
|
if (r?.ok) {
|
|
726
|
-
|
|
727
|
-
el('acc-code').value = ''
|
|
728
|
-
el('acc-paste').classList.remove('show')
|
|
881
|
+
endLogin()
|
|
729
882
|
// logging in is done: land back on the home panel, where the fresh live
|
|
730
883
|
// meters show up under a short-lived cheer — and the pet throws confetti
|
|
731
884
|
document.body.classList.remove('settings-open')
|
|
@@ -738,6 +891,8 @@ window.api.onAuthResult((r) => {
|
|
|
738
891
|
}, 6000)
|
|
739
892
|
celebrate()
|
|
740
893
|
} else {
|
|
894
|
+
// failed: put "Log in with browser" back, so a retry is one click away
|
|
895
|
+
document.body.classList.remove('awaiting')
|
|
741
896
|
const e = r?.error || ''
|
|
742
897
|
el('acc-msg').textContent = /429|rate_limit/i.test(e)
|
|
743
898
|
? 'Rate limited by Anthropic — wait a few minutes, then try once with a fresh code.'
|
|
@@ -794,14 +949,25 @@ el('close').addEventListener('click', () => window.api.quit())
|
|
|
794
949
|
el('usage').addEventListener('click', () => window.api.openUsage())
|
|
795
950
|
|
|
796
951
|
// account login (browser flow)
|
|
797
|
-
el('acc-connect').addEventListener('click', () =>
|
|
798
|
-
|
|
799
|
-
|
|
952
|
+
el('acc-connect').addEventListener('click', () => window.api.authStart())
|
|
953
|
+
// main opened the browser — the only thing left to do is paste the code back,
|
|
954
|
+
// so the panel narrows to exactly that
|
|
955
|
+
window.api.onAuthPending(() => {
|
|
956
|
+
// the login can start from the account menu, with the panel closed: the code
|
|
957
|
+
// field is where the flow continues, so bring it up
|
|
958
|
+
openSettings()
|
|
959
|
+
document.body.classList.add('awaiting')
|
|
960
|
+
el('acc-paste').classList.add('show')
|
|
961
|
+
el('acc-code').classList.remove('filled')
|
|
962
|
+
el('acc-code').focus()
|
|
800
963
|
fitSize()
|
|
801
964
|
})
|
|
802
|
-
// the Connect button only lights up once there's a code to submit
|
|
965
|
+
// the Connect button only lights up once there's a code to submit, and the
|
|
966
|
+
// field stops asking for attention once it has one
|
|
803
967
|
el('acc-code').addEventListener('input', () => {
|
|
804
|
-
|
|
968
|
+
const code = el('acc-code').value.trim()
|
|
969
|
+
el('acc-confirm').classList.toggle('ready', !!code)
|
|
970
|
+
el('acc-code').classList.toggle('filled', !!code)
|
|
805
971
|
})
|
|
806
972
|
el('acc-confirm').addEventListener('click', () => {
|
|
807
973
|
const code = el('acc-code').value.trim()
|
|
@@ -952,12 +1118,14 @@ for (const b of document.querySelectorAll('#set-mode .seg-btn')) {
|
|
|
952
1118
|
})
|
|
953
1119
|
}
|
|
954
1120
|
el('set-cancel').addEventListener('click', () => {
|
|
1121
|
+
abandonLogin()
|
|
955
1122
|
document.body.classList.remove('settings-open')
|
|
956
1123
|
clearSaveDirty()
|
|
957
1124
|
applyZoom(currentConfig?.zoom != null ? currentConfig.zoom : 100) // undo the preview
|
|
958
1125
|
fitSize()
|
|
959
1126
|
})
|
|
960
1127
|
el('set-save').addEventListener('click', () => {
|
|
1128
|
+
abandonLogin() // leaving the panel gives up on a login waiting for its code
|
|
961
1129
|
const num = (id) => parseFloat(el(id).value)
|
|
962
1130
|
const fire = num('set-fire')
|
|
963
1131
|
const zoomRaw = num('set-zoom')
|
|
@@ -1025,6 +1193,8 @@ if (typeof module === 'object' && module.exports) {
|
|
|
1025
1193
|
renderProjects,
|
|
1026
1194
|
renderHeat,
|
|
1027
1195
|
showProfile,
|
|
1196
|
+
renderAccounts,
|
|
1197
|
+
renderAccountMenu,
|
|
1028
1198
|
burn,
|
|
1029
1199
|
}
|
|
1030
1200
|
}
|
package/renderer/style.css
CHANGED
|
@@ -169,9 +169,10 @@ body.collapsed #usage {
|
|
|
169
169
|
z-index: 10;
|
|
170
170
|
display: flex;
|
|
171
171
|
align-items: center;
|
|
172
|
-
gap:
|
|
172
|
+
gap: 3px;
|
|
173
173
|
height: 19px; /* match #controls buttons so text centers on the same line */
|
|
174
|
-
|
|
174
|
+
/* #controls starts at ~154px: stop short of it, the pill has padding too */
|
|
175
|
+
max-width: 142px;
|
|
175
176
|
font-size: 9.5px;
|
|
176
177
|
line-height: 1;
|
|
177
178
|
color: var(--muted);
|
|
@@ -179,6 +180,106 @@ body.collapsed #usage {
|
|
|
179
180
|
#account-chip[hidden] {
|
|
180
181
|
display: none;
|
|
181
182
|
}
|
|
183
|
+
/* with something to switch to, the chip stops being a label and reads as a
|
|
184
|
+
control: a pill that holds the caret instead of leaving it floating */
|
|
185
|
+
#account-chip {
|
|
186
|
+
-webkit-app-region: no-drag;
|
|
187
|
+
cursor: pointer;
|
|
188
|
+
padding: 0 5px 0 6px;
|
|
189
|
+
margin-left: -6px; /* the text stays where it has always been */
|
|
190
|
+
border-radius: 10px;
|
|
191
|
+
background: rgba(255, 255, 255, 0.05);
|
|
192
|
+
transition:
|
|
193
|
+
background 0.15s,
|
|
194
|
+
color 0.15s;
|
|
195
|
+
}
|
|
196
|
+
#account-chip:hover {
|
|
197
|
+
background: rgba(255, 255, 255, 0.1);
|
|
198
|
+
color: var(--text);
|
|
199
|
+
}
|
|
200
|
+
/* a drawn chevron, not the ▾ glyph: the glyph's ink sits high in its em box, so
|
|
201
|
+
it never lines up with the dot and the text */
|
|
202
|
+
.ac-caret {
|
|
203
|
+
flex: none;
|
|
204
|
+
width: 9px;
|
|
205
|
+
height: 9px;
|
|
206
|
+
margin-left: -2px; /* hugs the email rather than drifting off it */
|
|
207
|
+
fill: none;
|
|
208
|
+
stroke: currentColor;
|
|
209
|
+
stroke-width: 1.6;
|
|
210
|
+
stroke-linecap: round;
|
|
211
|
+
stroke-linejoin: round;
|
|
212
|
+
}
|
|
213
|
+
/* a single account keeps the pill, minus the affordance: nothing to switch to.
|
|
214
|
+
The email then takes the width the chevron leaves behind. */
|
|
215
|
+
body.one-account #account-chip {
|
|
216
|
+
cursor: default;
|
|
217
|
+
}
|
|
218
|
+
body.one-account #account-chip:hover {
|
|
219
|
+
background: rgba(255, 255, 255, 0.05);
|
|
220
|
+
color: var(--muted);
|
|
221
|
+
}
|
|
222
|
+
body.one-account .ac-caret {
|
|
223
|
+
display: none;
|
|
224
|
+
}
|
|
225
|
+
/* the widget is mostly a drag region, and those swallow clicks before the page
|
|
226
|
+
ever sees them — so an invisible sheet under the menu catches them instead */
|
|
227
|
+
#acc-backdrop {
|
|
228
|
+
position: fixed;
|
|
229
|
+
inset: 0;
|
|
230
|
+
z-index: 19;
|
|
231
|
+
-webkit-app-region: no-drag;
|
|
232
|
+
}
|
|
233
|
+
#acc-backdrop[hidden] {
|
|
234
|
+
display: none;
|
|
235
|
+
}
|
|
236
|
+
/* dropdown under the chip; it overlays the pet rather than resizing the window */
|
|
237
|
+
#acc-menu {
|
|
238
|
+
position: absolute;
|
|
239
|
+
top: 24px;
|
|
240
|
+
left: 8px;
|
|
241
|
+
z-index: 20;
|
|
242
|
+
min-width: 118px;
|
|
243
|
+
max-width: 176px;
|
|
244
|
+
max-height: 120px;
|
|
245
|
+
overflow-y: auto;
|
|
246
|
+
padding: 3px;
|
|
247
|
+
border: 0.5px solid rgba(255, 210, 180, 0.1);
|
|
248
|
+
border-radius: 7px;
|
|
249
|
+
background: #221a15;
|
|
250
|
+
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.5);
|
|
251
|
+
-webkit-app-region: no-drag;
|
|
252
|
+
}
|
|
253
|
+
#acc-menu[hidden] {
|
|
254
|
+
display: none;
|
|
255
|
+
}
|
|
256
|
+
#acc-menu.busy {
|
|
257
|
+
pointer-events: none; /* one switch at a time */
|
|
258
|
+
}
|
|
259
|
+
/* the add entry closes the list: it belongs to no account */
|
|
260
|
+
#acc-menu .acc-add {
|
|
261
|
+
margin-top: 2px;
|
|
262
|
+
padding-top: 6px;
|
|
263
|
+
border-top: 0.5px solid var(--hair);
|
|
264
|
+
border-radius: 0;
|
|
265
|
+
color: var(--coral-soft);
|
|
266
|
+
/* no .who inside this one, so it needs the row font itself */
|
|
267
|
+
font-size: 9.5px;
|
|
268
|
+
white-space: nowrap;
|
|
269
|
+
}
|
|
270
|
+
#acc-menu .acc-add:hover {
|
|
271
|
+
background: none;
|
|
272
|
+
color: var(--coral);
|
|
273
|
+
}
|
|
274
|
+
/* tighter than the dropdown-less list it replaced: it floats over the pet */
|
|
275
|
+
#acc-menu .acc-item {
|
|
276
|
+
gap: 6px;
|
|
277
|
+
padding: 4px 6px;
|
|
278
|
+
border-radius: 5px;
|
|
279
|
+
}
|
|
280
|
+
#acc-menu .acc-item .who {
|
|
281
|
+
font-size: 9.5px;
|
|
282
|
+
}
|
|
182
283
|
.ac-dot {
|
|
183
284
|
flex: none;
|
|
184
285
|
width: 6px;
|
|
@@ -187,6 +288,11 @@ body.collapsed #usage {
|
|
|
187
288
|
background: var(--pixel);
|
|
188
289
|
}
|
|
189
290
|
#ac-email {
|
|
291
|
+
/* line-height:1 leaves the ascender inside the box, so the text reads a hair
|
|
292
|
+
lower than the dot next to it — lift it back onto the same line */
|
|
293
|
+
transform: translateY(-1px);
|
|
294
|
+
flex: 1; /* claim the leftover width so the ellipsis is not left hanging */
|
|
295
|
+
min-width: 0;
|
|
190
296
|
overflow: hidden;
|
|
191
297
|
text-overflow: ellipsis;
|
|
192
298
|
white-space: nowrap;
|
|
@@ -683,6 +789,88 @@ body.live .live-only {
|
|
|
683
789
|
gap: 10px; /* the email ellipsizes instead of touching Disconnect */
|
|
684
790
|
flex-wrap: wrap; /* the success line drops to its own row */
|
|
685
791
|
}
|
|
792
|
+
/* a plain list, not a stack of cards: the panel already has enough boxes */
|
|
793
|
+
.acc-item {
|
|
794
|
+
display: flex;
|
|
795
|
+
align-items: center;
|
|
796
|
+
gap: 7px;
|
|
797
|
+
padding: 5px 6px;
|
|
798
|
+
border-radius: 6px;
|
|
799
|
+
cursor: pointer;
|
|
800
|
+
color: var(--muted);
|
|
801
|
+
transition:
|
|
802
|
+
background 0.15s,
|
|
803
|
+
color 0.15s;
|
|
804
|
+
}
|
|
805
|
+
.acc-item:hover {
|
|
806
|
+
background: rgba(255, 255, 255, 0.06);
|
|
807
|
+
color: var(--text);
|
|
808
|
+
}
|
|
809
|
+
.acc-item.active {
|
|
810
|
+
background: rgba(217, 119, 87, 0.14);
|
|
811
|
+
color: var(--text);
|
|
812
|
+
cursor: default;
|
|
813
|
+
}
|
|
814
|
+
.acc-item .dot {
|
|
815
|
+
width: 5px;
|
|
816
|
+
height: 5px;
|
|
817
|
+
border-radius: 50%;
|
|
818
|
+
background: rgba(255, 255, 255, 0.22); /* logged out: a row with no usage yet */
|
|
819
|
+
flex: none;
|
|
820
|
+
}
|
|
821
|
+
.acc-item.connected .dot {
|
|
822
|
+
background: var(--green);
|
|
823
|
+
}
|
|
824
|
+
.acc-item .who {
|
|
825
|
+
flex: 1;
|
|
826
|
+
min-width: 0;
|
|
827
|
+
font-size: 11px;
|
|
828
|
+
overflow: hidden;
|
|
829
|
+
text-overflow: ellipsis;
|
|
830
|
+
white-space: nowrap;
|
|
831
|
+
}
|
|
832
|
+
/* switching re-reads that account's logs, which takes a beat — say so */
|
|
833
|
+
.acc-item.loading .dot {
|
|
834
|
+
background: var(--coral);
|
|
835
|
+
animation: acc-pulse 0.9s ease-in-out infinite;
|
|
836
|
+
}
|
|
837
|
+
.acc-item.loading .who {
|
|
838
|
+
opacity: 0.6;
|
|
839
|
+
}
|
|
840
|
+
@keyframes acc-pulse {
|
|
841
|
+
0%,
|
|
842
|
+
100% {
|
|
843
|
+
opacity: 1;
|
|
844
|
+
}
|
|
845
|
+
50% {
|
|
846
|
+
opacity: 0.2;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
.acc-item .drop {
|
|
850
|
+
border: none;
|
|
851
|
+
background: none;
|
|
852
|
+
color: var(--muted);
|
|
853
|
+
font-size: 12px;
|
|
854
|
+
line-height: 1;
|
|
855
|
+
padding: 0 2px;
|
|
856
|
+
cursor: pointer;
|
|
857
|
+
opacity: 0.3; /* faint until hover: this deletes a token */
|
|
858
|
+
transition: opacity 0.15s;
|
|
859
|
+
}
|
|
860
|
+
.acc-item:hover .drop {
|
|
861
|
+
opacity: 0.75;
|
|
862
|
+
}
|
|
863
|
+
.acc-item .drop:hover {
|
|
864
|
+
opacity: 1;
|
|
865
|
+
color: var(--text);
|
|
866
|
+
}
|
|
867
|
+
/* armed: the confirm has to be legible whether or not the cursor is on the row */
|
|
868
|
+
.acc-item .drop.armed,
|
|
869
|
+
.acc-item:hover .drop.armed {
|
|
870
|
+
opacity: 1;
|
|
871
|
+
font-size: 10px;
|
|
872
|
+
color: var(--coral);
|
|
873
|
+
}
|
|
686
874
|
#acc-paste {
|
|
687
875
|
display: none;
|
|
688
876
|
margin-top: 8px;
|
|
@@ -690,6 +878,20 @@ body.live .live-only {
|
|
|
690
878
|
#acc-paste.show {
|
|
691
879
|
display: block;
|
|
692
880
|
}
|
|
881
|
+
/* while the browser is open, this empty field is the whole flow: make it say so */
|
|
882
|
+
body.awaiting #acc-code:not(.filled) {
|
|
883
|
+
border-color: var(--coral);
|
|
884
|
+
animation: code-wait 1.6s ease-in-out infinite;
|
|
885
|
+
}
|
|
886
|
+
@keyframes code-wait {
|
|
887
|
+
0%,
|
|
888
|
+
100% {
|
|
889
|
+
box-shadow: 0 0 0 0 rgba(217, 119, 87, 0);
|
|
890
|
+
}
|
|
891
|
+
50% {
|
|
892
|
+
box-shadow: 0 0 0 3px rgba(217, 119, 87, 0.22);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
693
895
|
#acc-code {
|
|
694
896
|
width: 100%;
|
|
695
897
|
margin: 4px 0 6px;
|
|
@@ -712,6 +914,15 @@ body.auth-on #acc-disconnected {
|
|
|
712
914
|
body.auth-on #acc-connected {
|
|
713
915
|
display: flex;
|
|
714
916
|
}
|
|
917
|
+
/* the browser is already open on the login page: the button that opens it and
|
|
918
|
+
the pitch for connecting are both noise now — only the code field matters */
|
|
919
|
+
body.awaiting #acc-connect,
|
|
920
|
+
body.awaiting #acc-intro {
|
|
921
|
+
display: none;
|
|
922
|
+
}
|
|
923
|
+
#acc-disconnected {
|
|
924
|
+
margin-top: 10px;
|
|
925
|
+
}
|
|
715
926
|
|
|
716
927
|
/* just logged in: a short-lived green cheer at the top of the home panel */
|
|
717
928
|
#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
|
+
}
|