@cad0p/pi-usage 0.11.3 → 0.11.4

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.
@@ -0,0 +1,603 @@
1
+ // Pure logic for the AI Usage panel.
2
+ //
3
+ // No QML imports and no wall clock: Panel.qml and UsageContent.qml load this
4
+ // file as a JS module (import "UsageLogic.js" as Logic), and the node tests
5
+ // evaluate it in a sandbox. Every function that needs "now" takes it as an
6
+ // argument.
7
+ //
8
+ // The window, model-name and token formatting helpers are carried over from
9
+ // Omarchy's built-in Agents plugin (plugins/agents, MIT, by Omarchy) so the
10
+ // two panels read a record the same way.
11
+
12
+ function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)) }
13
+
14
+ // A record's lists arrive as JS arrays from JSON.parse, but once they pass
15
+ // through a Repeater's modelData they are QML sequences, which
16
+ // Array.isArray rejects. Anything with a numeric length is a list here.
17
+ function isList(value) {
18
+ return !!value && typeof value === "object" && typeof value.length === "number"
19
+ }
20
+
21
+ function toArray(value) {
22
+ if (!isList(value)) return []
23
+ var out = []
24
+ for (var i = 0; i < value.length; i++) out.push(value[i])
25
+ return out
26
+ }
27
+
28
+ function numberValue(value) {
29
+ var n = Number(value || 0)
30
+ return isFinite(n) ? Math.round(n) : 0
31
+ }
32
+
33
+ // ------------------------------------------------------------------ windows
34
+
35
+ // Claude spells its windows out ("Session (5-hour)"), Codex abbreviates them
36
+ // ("5h window", "30m window"). Both have to land on the same record.
37
+ function windowIsLong(text) {
38
+ return text.indexOf("week") >= 0 || text.indexOf("7-day") >= 0 || text.indexOf("seven") >= 0
39
+ || text.indexOf("month") >= 0 || text.indexOf("30-day") >= 0
40
+ }
41
+
42
+ function windowSpanMs(label) {
43
+ var text = String(label || "").toLowerCase()
44
+ if (text.indexOf("month") >= 0 || text.indexOf("30-day") >= 0) return 30 * 24 * 3600 * 1000
45
+ if (windowIsLong(text)) return 7 * 24 * 3600 * 1000
46
+ var hours = text.match(/(\d+)\s*-?\s*h(?:our)?\b/)
47
+ if (hours) return Number(hours[1]) * 3600 * 1000
48
+ var minutes = text.match(/(\d+)\s*-?\s*m(?:in(?:ute)?s?)?\b/)
49
+ if (minutes) return Number(minutes[1]) * 60 * 1000
50
+ return 0
51
+ }
52
+
53
+ function windowTitle(label) {
54
+ var text = String(label || "").toLowerCase()
55
+ if (text.indexOf("month") >= 0) return "Monthly"
56
+ if (windowIsLong(text)) return "Weekly"
57
+ if (text.indexOf("session") >= 0 || windowSpanMs(label) > 0) return "Session"
58
+ var plain = String(label || "").replace(/\s*\(.*\)\s*/, "").trim()
59
+ return plain === "" ? "Limit" : plain
60
+ }
61
+
62
+ // A collector that already knows which window a limit belongs to says so in
63
+ // `title`, and that beats reading it back out of the label.
64
+ function limitWindow(label, percent, resetAt, title) {
65
+ return {
66
+ title: String(title || "") !== "" ? String(title) : windowTitle(label),
67
+ percent: Number(percent),
68
+ resetAt: String(resetAt || "")
69
+ }
70
+ }
71
+
72
+ // Negative percentages are placeholders (an error record keeps its tab with
73
+ // one), never drawn.
74
+ function limitWindows(list) {
75
+ var out = []
76
+ list = toArray(list)
77
+ for (var i = 0; i < list.length; i++) {
78
+ var entry = list[i] || {}
79
+ var percent = Number(entry.percent)
80
+ if (entry.percent !== null && entry.percent !== undefined && entry.percent !== "" && percent >= 0) {
81
+ percent = clamp(percent, 0, 1)
82
+ out.push(limitWindow(entry.label, percent, entry.resetsAt, entry.title))
83
+ }
84
+ }
85
+ return out
86
+ }
87
+
88
+ function resetOrder(w) {
89
+ if (!w || w.resetAt === "") return Infinity
90
+ var ms = new Date(w.resetAt).getTime()
91
+ return isFinite(ms) ? ms : Infinity
92
+ }
93
+
94
+ // The window that decides how much room is left: the fullest one, and on a
95
+ // tie the one that resets first.
96
+ function bindingWindow(windows) {
97
+ var best = null
98
+ for (var i = 0; i < (windows || []).length; i++) {
99
+ var w = windows[i]
100
+ if (!best || w.percent > best.percent
101
+ || (w.percent === best.percent && resetOrder(w) < resetOrder(best))) best = w
102
+ }
103
+ return best
104
+ }
105
+
106
+ // The short words /usage uses for an account's windows.
107
+ function windowTag(title) {
108
+ var text = String(title || "")
109
+ if (text === "Session") return "session"
110
+ if (text === "Weekly") return "week"
111
+ if (text === "Monthly") return "month"
112
+ return text.toLowerCase()
113
+ }
114
+
115
+ function resetMs(w, nowMs) {
116
+ if (!w || !w.resetAt) return -1
117
+ var ms = new Date(w.resetAt).getTime()
118
+ return isFinite(ms) ? ms - nowMs : -1
119
+ }
120
+
121
+ // "Resets in 2d 4h": the built-in panel's countdown.
122
+ function formatDuration(ms) {
123
+ if (!(ms > 0)) return "now"
124
+ var minutes = Math.floor(ms / 60000)
125
+ var hours = Math.floor(minutes / 60)
126
+ var days = Math.floor(hours / 24)
127
+ if (days > 0) return days + "d " + (hours % 24) + "h"
128
+ if (hours > 0) return hours + "h " + (minutes % 60) + "m"
129
+ return Math.max(1, minutes) + "m"
130
+ }
131
+
132
+ // One unit, for the dense rows: 10m, 4h, 19d — the same reading as /usage.
133
+ function compactDuration(ms) {
134
+ if (!(ms > 0)) return "now"
135
+ var minutes = Math.floor(ms / 60000)
136
+ if (minutes < 60) return Math.max(1, minutes) + "m"
137
+ var hours = Math.floor(minutes / 60)
138
+ if (hours < 24) return hours + "h"
139
+ return Math.floor(hours / 24) + "d"
140
+ }
141
+
142
+ // ----------------------------------------------------------------- accounts
143
+
144
+ // One row per account of a multi-account record, every window kept.
145
+ function accountRows(p) {
146
+ var list = p && isList(p.accounts) ? p.accounts : []
147
+ var rows = []
148
+ for (var i = 0; i < list.length; i++) {
149
+ var entry = list[i] || {}
150
+ var windows = limitWindows(entry.limits)
151
+ rows.push({
152
+ id: String(entry.id || ("#" + (i + 1))),
153
+ ok: entry.ok === true,
154
+ status: String(entry.status || ""),
155
+ windows: windows,
156
+ binding: bindingWindow(windows)
157
+ })
158
+ }
159
+ return rows
160
+ }
161
+
162
+ function failedAccountCount(p) {
163
+ var rows = accountRows(p)
164
+ var failed = 0
165
+ for (var i = 0; i < rows.length; i++) if (!rows[i].ok) failed++
166
+ return failed
167
+ }
168
+
169
+ // What the section header and the bar alarm read.
170
+ //
171
+ // A single account is bound by its fullest window. Several accounts of one
172
+ // family are used one after another (pi rotates to the next slot when one is
173
+ // exhausted), so what stops the next prompt is the account with the most
174
+ // room left: the healthy account whose own fullest window is the emptiest.
175
+ // That is the "lowest" figure /usage prints for the family.
176
+ function headline(p) {
177
+ if (!p) return null
178
+ var rows = accountRows(p)
179
+ if (rows.length > 0) {
180
+ var best = null
181
+ for (var i = 0; i < rows.length; i++) {
182
+ var row = rows[i]
183
+ if (!row.ok || !row.binding) continue
184
+ if (!best || row.binding.percent < best.binding.percent
185
+ || (row.binding.percent === best.binding.percent && resetOrder(row.binding) < resetOrder(best.binding)))
186
+ best = row
187
+ }
188
+ if (best) return {
189
+ title: best.id + " · " + best.binding.title,
190
+ percent: best.binding.percent,
191
+ resetAt: best.binding.resetAt,
192
+ accountId: best.id
193
+ }
194
+ // Every account failed: fall through to the flattened limits, which then
195
+ // only hold the placeholder and yield nothing.
196
+ }
197
+ return bindingWindow(limitWindows(p.limits))
198
+ }
199
+
200
+ function urgency(p) {
201
+ var h = headline(p)
202
+ if (h) return h.percent
203
+ if (p && p.balance && p.balance.funded > 0)
204
+ return 1 - clamp(p.balance.remaining / p.balance.funded, 0, 1)
205
+ return -1
206
+ }
207
+
208
+ // ---------------------------------------------------------------- expansion
209
+
210
+ // With nothing chosen yet, the section that is closest to stopping you opens
211
+ // and the rest stay folded: their headers already carry the headline, so the
212
+ // panel answers "am I about to run out?" without a keystroke. Ties go to the
213
+ // window that resets first; with no limits anywhere the first section opens.
214
+ function defaultExpandedId(providers) {
215
+ var best = null
216
+ var bestUrgency = -Infinity
217
+ var bestReset = Infinity
218
+ for (var i = 0; i < (providers || []).length; i++) {
219
+ var p = providers[i]
220
+ var u = urgency(p)
221
+ var h = headline(p)
222
+ var r = h ? resetOrder(h) : Infinity
223
+ if (best === null || u > bestUrgency || (u === bestUrgency && r < bestReset)) {
224
+ best = p
225
+ bestUrgency = u
226
+ bestReset = r
227
+ }
228
+ }
229
+ return best ? String(best.providerId) : ""
230
+ }
231
+
232
+ // An explicit choice sticks; an untouched section follows the default.
233
+ function isExpanded(id, overrides, defaultId) {
234
+ if (overrides && Object.prototype.hasOwnProperty.call(overrides, id)) return overrides[id] === true
235
+ return id === defaultId
236
+ }
237
+
238
+ function withExpanded(overrides, id, expanded) {
239
+ var next = {}
240
+ for (var key in (overrides || {})) next[key] = overrides[key]
241
+ next[id] = expanded === true
242
+ return next
243
+ }
244
+
245
+ function parseUiState(text) {
246
+ try {
247
+ var parsed = JSON.parse(String(text || ""))
248
+ var expanded = parsed && parsed.expanded && typeof parsed.expanded === "object" ? parsed.expanded : {}
249
+ var clean = {}
250
+ for (var key in expanded) if (typeof expanded[key] === "boolean") clean[key] = expanded[key]
251
+ return { expanded: clean }
252
+ } catch (e) {
253
+ return { expanded: {} }
254
+ }
255
+ }
256
+
257
+ function serializeUiState(state) {
258
+ return JSON.stringify({ schemaVersion: 1, expanded: (state && state.expanded) || {} }, null, 2) + "\n"
259
+ }
260
+
261
+ // ---------------------------------------------------------------- providers
262
+
263
+ function balanceValue(raw) {
264
+ if (!raw || typeof raw !== "object") return null
265
+ var remaining = Number(raw.remaining)
266
+ var funded = Number(raw.funded)
267
+ if (!isFinite(remaining) || remaining < 0) return null
268
+ return {
269
+ remaining: remaining,
270
+ funded: isFinite(funded) && funded > 0 ? funded : 0,
271
+ spent: Math.max(0, Number(raw.spent) || 0),
272
+ currency: String(raw.currency || "USD"),
273
+ estimated: raw.estimated === true
274
+ }
275
+ }
276
+
277
+ // The record, normalized the way the built-in Main.qml does (minus the
278
+ // cross-device merge), plus the accounts list.
279
+ function displayProvider(record) {
280
+ record = record || {}
281
+ return {
282
+ providerId: String(record.id),
283
+ providerName: String(record.name || record.id),
284
+ ready: record.ready === true,
285
+ usageStatusText: String(record.usageStatusText || ""),
286
+ authHelpText: String(record.authHelpText || ""),
287
+ limits: isList(record.limits) ? record.limits : [],
288
+ accounts: isList(record.accounts) ? record.accounts : [],
289
+ tierLabel: String(record.tierLabel || ""),
290
+ balance: balanceValue(record.balance),
291
+ todayPrompts: numberValue(record.todayPrompts),
292
+ todaySessions: numberValue(record.todaySessions),
293
+ todayTotalTokens: numberValue(record.todayTotalTokens),
294
+ recentDays: isList(record.recentDays) ? record.recentDays : [],
295
+ totalPrompts: numberValue(record.totalPrompts),
296
+ totalSessions: numberValue(record.totalSessions),
297
+ activeDays: numberValue(record.activeDays),
298
+ modelUsage: record.modelUsage && typeof record.modelUsage === "object" ? record.modelUsage : {},
299
+ hasLocalStats: record.hasLocalStats !== false,
300
+ hasPromptStats: record.hasPromptStats !== false
301
+ }
302
+ }
303
+
304
+ // The built-in rule: a provider earns a section by having produced numbers.
305
+ function providerHasData(p) {
306
+ return numberValue(p.totalPrompts) > 0 || numberValue(p.totalSessions) > 0
307
+ || numberValue(p.activeDays) > 0 || numberValue(p.todayPrompts) > 0
308
+ || numberValue(p.todaySessions) > 0 || (p.limits && p.limits.length > 0)
309
+ || (p.accounts && p.accounts.length > 0) || !!p.balance
310
+ }
311
+
312
+ function providerEnabled(settings, id) {
313
+ if (!settings || !settings.providers || !settings.providers[id]) return true
314
+ return settings.providers[id].enabled !== false
315
+ }
316
+
317
+ function pluginVisible(providers) {
318
+ return isList(providers) && providers.length > 0
319
+ }
320
+
321
+ function uiStateDirectory(stateHome, override) {
322
+ var path = String(override || "")
323
+ return path !== "" ? path : String(stateHome || "") + "/omarchy/settings"
324
+ }
325
+
326
+ function queueUpdateKind(pending, requested) {
327
+ if (requested === "force" || pending === "") return requested
328
+ return pending
329
+ }
330
+
331
+ // Whether this plugin may run Omarchy's collectors at all. "On" always, "Auto"
332
+ // only while the built-in Agents widget is off the bar (so keeping both never
333
+ // doubles the work), "Off" never. Anything unrecognized reads as Auto, the
334
+ // manifest default, so a missing or mistyped setting cannot switch the
335
+ // collectors on.
336
+ function collectorsWanted(setting, builtinOnBar) {
337
+ var mode = String(setting === null || setting === undefined ? "Auto" : setting).trim().toLowerCase()
338
+ if (mode === "off") return false
339
+ if (mode === "on") return true
340
+ return builtinOnBar !== true
341
+ }
342
+
343
+ // A collector that could not reach its limits endpoint writes retryAdvised
344
+ // into its record. A retry is a collector run like any other, so it inherits
345
+ // the same switch: with the setting Off, or Auto while the built-in widget is
346
+ // on the bar, no id comes back and nothing runs.
347
+ function advisedRetryIds(records, settings, collectors) {
348
+ if (!collectors) return []
349
+ var ids = []
350
+ records = toArray(records)
351
+ for (var i = 0; i < records.length; i++) {
352
+ var record = records[i]
353
+ var id = record && String(record.id || "")
354
+ if (id && record.retryAdvised === true && providerEnabled(settings, id)) ids.push(id)
355
+ }
356
+ return ids
357
+ }
358
+
359
+ function recordTime(record) {
360
+ var ms = Date.parse(String(record && record.updatedAt || ""))
361
+ return isFinite(ms) ? ms : -Infinity
362
+ }
363
+
364
+ // One id is one section. A stale file, or a fixture dropped next to a live
365
+ // record, can claim an id that already exists; the newest updatedAt wins, so
366
+ // the panel never draws a provider twice and the second copy cannot share the
367
+ // first one's expansion state.
368
+ function newestRecords(records) {
369
+ var chosen = {}
370
+ var order = []
371
+ for (var i = 0; i < (records || []).length; i++) {
372
+ var record = records[i]
373
+ if (!record || !record.id) continue
374
+ var id = String(record.id)
375
+ if (!Object.prototype.hasOwnProperty.call(chosen, id)) {
376
+ chosen[id] = record
377
+ order.push(id)
378
+ continue
379
+ }
380
+ if (recordTime(record) >= recordTime(chosen[id])) chosen[id] = record
381
+ }
382
+ var out = []
383
+ for (var j = 0; j < order.length; j++) out.push(chosen[order[j]])
384
+ return out
385
+ }
386
+
387
+ // Records sorted by id, the built-in's order, enabled and non-empty only.
388
+ function visibleProviders(records, settings) {
389
+ var list = []
390
+ var unique = newestRecords(records)
391
+ for (var i = 0; i < unique.length; i++) {
392
+ var record = unique[i]
393
+ if (!providerEnabled(settings, String(record.id))) continue
394
+ var p = displayProvider(record)
395
+ if (providerHasData(p)) list.push(p)
396
+ }
397
+ list.sort(function(a, b) { return a.providerId < b.providerId ? -1 : (a.providerId > b.providerId ? 1 : 0) })
398
+ return list
399
+ }
400
+
401
+ // The plan line under the name. A provider that cannot report says why
402
+ // instead, the same substitution the built-in hero makes.
403
+ function headerMeta(p) {
404
+ if (!p) return ""
405
+ if (!p.ready && p.usageStatusText !== "") return p.usageStatusText
406
+ var tier = String(p.tierLabel || "")
407
+ if (tier === "") return "Subscription"
408
+ return tier.charAt(0).toUpperCase() + tier.slice(1)
409
+ }
410
+
411
+ // A short chip on the header when something needs attention.
412
+ function statusChip(p) {
413
+ if (!p) return ""
414
+ if (!p.ready) return p.usageStatusText !== "" ? "Unavailable" : ""
415
+ var failed = failedAccountCount(p)
416
+ if (failed > 0) return failed + " down"
417
+ return ""
418
+ }
419
+
420
+ // Marks resolve by convention: assets/<family>.svg for a pi- record
421
+ // (pi-claude -> claude.svg), then assets/<id>.svg, each with a -light twin
422
+ // tried first on light surfaces.
423
+ // Marks that need a dark twin on light surfaces; every other shipped mark
424
+ // reads on both. Listing them keeps the lookup from logging a failed load
425
+ // for a -light file that was never shipped.
426
+ var LIGHT_TWINS = { codex: true }
427
+
428
+ function iconCandidates(providerId, lightSurface) {
429
+ var id = String(providerId || "")
430
+ // The family first: this plugin ships family marks, so a pi- record finds
431
+ // its mark on the first try instead of logging a failed load.
432
+ var names = id.indexOf("pi-") === 0 && id.length > 3 ? [id.slice(3), id] : [id]
433
+ var out = []
434
+ for (var i = 0; i < names.length; i++) {
435
+ if (lightSurface && LIGHT_TWINS[names[i]] === true) out.push("assets/" + names[i] + "-light.svg")
436
+ out.push("assets/" + names[i] + ".svg")
437
+ }
438
+ return out
439
+ }
440
+
441
+ // -------------------------------------------------------------- local stats
442
+
443
+ // A corrupt or hand-written record can carry a count no provider will ever
444
+ // report (1e299, Infinity). Those must not reach the panel as "1e+299B" or
445
+ // "InfinityB": a non-finite count renders as an em dash and the unit is capped
446
+ // at billions.
447
+ var MAX_TOKEN_COUNT = 999.9e9
448
+
449
+ function formatTokenCount(n) {
450
+ if (n === undefined || n === null) return "0"
451
+ var value = Number(n)
452
+ if (!isFinite(value)) return "—"
453
+ n = Math.min(value, MAX_TOKEN_COUNT)
454
+ if (n >= 1e9) return (n / 1e9).toFixed(1) + "B"
455
+ if (n >= 1e6) return (n / 1e6).toFixed(1) + "M"
456
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + "K"
457
+ return String(n)
458
+ }
459
+
460
+ function modelWordCase(word) {
461
+ if (word === "gpt") return "GPT"
462
+ if (word === "deepseek") return "DeepSeek"
463
+ return word.charAt(0).toUpperCase() + word.slice(1)
464
+ }
465
+
466
+ // `claude-opus-4-8` -> "Opus 4.8", `gpt-5.6-sol` -> "GPT 5.6 Sol".
467
+ function friendlyModelName(id) {
468
+ if (!id) return "Unknown"
469
+ var name = String(id).replace(/^claude-/, "").replace(/-\d{8}$/, "")
470
+ var parts = name.split("-")
471
+ var words = []
472
+ var version = []
473
+ for (var i = 0; i < parts.length; i++) {
474
+ var part = parts[i]
475
+ if (part === "") continue
476
+ if (/^\d/.test(part)) {
477
+ version.push(part)
478
+ continue
479
+ }
480
+ if (version.length > 0) {
481
+ words.push(version.join("."))
482
+ version = []
483
+ }
484
+ words.push(modelWordCase(part))
485
+ }
486
+ if (version.length > 0) words.push(version.join("."))
487
+ return words.length > 0 ? words.join(" ") : "Unknown"
488
+ }
489
+
490
+ function recentDayRows(p) {
491
+ var days = p && isList(p.recentDays) ? p.recentDays : []
492
+ var rows = []
493
+ for (var i = 0; i < days.length; i++) {
494
+ var day = days[i]
495
+ if (day && typeof day === "object") rows.push(day)
496
+ }
497
+ return rows
498
+ }
499
+
500
+ function hasDays(p) {
501
+ return !!p && p.hasLocalStats !== false && recentDayRows(p).length > 0
502
+ }
503
+
504
+ function weekPeak(p) {
505
+ var days = p ? (p.recentDays || []) : []
506
+ var peak = 0
507
+ for (var i = 0; i < days.length; i++) {
508
+ var day = days[i]
509
+ peak = Math.max(peak, Number(day && day.messageCount || 0))
510
+ }
511
+ return peak
512
+ }
513
+
514
+ // The heaviest four models, as the built-in shows them.
515
+ function modelRows(p) {
516
+ var usageByModel = p && p.hasLocalStats !== false ? (p.modelUsage || {}) : {}
517
+ var rows = []
518
+ for (var id in usageByModel) {
519
+ var bucket = usageByModel[id] || {}
520
+ var input = Number(bucket.inputTokens || 0)
521
+ var output = Number(bucket.outputTokens || 0)
522
+ var cacheRead = Number(bucket.cacheReadInputTokens || 0)
523
+ var cacheWrite = Number(bucket.cacheCreationInputTokens || 0)
524
+ rows.push({
525
+ name: friendlyModelName(id),
526
+ total: input + output + cacheRead + cacheWrite,
527
+ input: input,
528
+ output: output,
529
+ cacheRead: cacheRead,
530
+ cacheWrite: cacheWrite
531
+ })
532
+ }
533
+ rows.sort(function(a, b) { return b.total - a.total })
534
+ return rows.slice(0, 4)
535
+ }
536
+
537
+ function modelTooltip(row) {
538
+ if (!row) return ""
539
+ return "In " + formatTokenCount(row.input)
540
+ + " · out " + formatTokenCount(row.output)
541
+ + " · cache read " + formatTokenCount(row.cacheRead)
542
+ + " · cache write " + formatTokenCount(row.cacheWrite)
543
+ }
544
+
545
+ function todayDate(nowMs) {
546
+ var now = new Date(nowMs)
547
+ return now.getFullYear()
548
+ + "-" + String(now.getMonth() + 1).padStart(2, "0")
549
+ + "-" + String(now.getDate()).padStart(2, "0")
550
+ }
551
+
552
+ function dayName(date) {
553
+ var parsed = new Date(String(date || "") + "T00:00:00")
554
+ if (isNaN(parsed.getTime())) return String(date || "")
555
+ return ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][parsed.getDay()]
556
+ }
557
+
558
+ function dayLabel(date, today) {
559
+ return today ? "Today" : dayName(date)
560
+ }
561
+
562
+ function dayTooltip(day, today, p) {
563
+ if (!day) return ""
564
+ var parsed = new Date(String(day.date) + "T00:00:00")
565
+ var label = isNaN(parsed.getTime())
566
+ ? String(day.date)
567
+ : dayName(day.date) + " " + (parsed.getMonth() + 1) + "/" + parsed.getDate()
568
+ var text = label + " · " + formatTokenCount(Number(day.messageCount || 0)) + " tokens"
569
+ if (today && p && p.hasPromptStats !== false)
570
+ text += " · " + Number(p.todayPrompts || 0) + " prompts · " + Number(p.todaySessions || 0) + " sessions"
571
+ return text
572
+ }
573
+
574
+ // ------------------------------------------------------------------ balance
575
+
576
+ function currencyPrefix(currency) {
577
+ var code = String(currency || "USD").toUpperCase()
578
+ if (code === "USD") return "$"
579
+ if (code === "EUR") return "€"
580
+ if (code === "GBP") return "£"
581
+ return code + " "
582
+ }
583
+
584
+ var MAX_MONEY_AMOUNT = 999999.99
585
+
586
+ function formatMoney(value, currency) {
587
+ var amount = Number(value)
588
+ // A non-finite amount is unknown, not zero: "$0.00" would read as an empty balance.
589
+ if (!isFinite(amount)) return "—"
590
+ amount = Math.max(-MAX_MONEY_AMOUNT, Math.min(amount, MAX_MONEY_AMOUNT))
591
+ return currencyPrefix(currency) + amount.toFixed(2)
592
+ }
593
+
594
+ function balanceDetailText(b) {
595
+ if (!b || !(b.funded > 0)) return ""
596
+ var text = formatMoney(b.spent, b.currency) + " spent of " + formatMoney(b.funded, b.currency) + " funded"
597
+ if (b.estimated) text += " · estimated"
598
+ return text
599
+ }
600
+
601
+ function balanceAlarming(b) {
602
+ return !!b && b.funded > 0 && b.remaining / b.funded <= 0.1
603
+ }
@@ -0,0 +1,3 @@
1
+ <svg viewBox="0 0 24 24" width="24" height="24" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="M12 0C12 6.627 6.627 12 0 12c6.627 0 12 5.373 12 12 0-6.627 5.373-12 12-12-6.627 0-12-5.373-12-12z" fill="#B877DB"/>
3
+ </svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="256" height="257" preserveAspectRatio="xMidYMid" viewBox="0 0 256 257"><path fill="#D97757" d="m50.228 170.321 50.357-28.257.843-2.463-.843-1.361h-2.462l-8.426-.518-28.775-.778-24.952-1.037-24.175-1.296-6.092-1.297L0 125.796l.583-3.759 5.12-3.434 7.324.648 16.202 1.101 24.304 1.685 17.629 1.037 26.118 2.722h4.148l.583-1.685-1.426-1.037-1.101-1.037-25.147-17.045-27.22-18.017-14.258-10.37-7.713-5.25-3.888-4.925-1.685-10.758 7-7.713 9.397.649 2.398.648 9.527 7.323 20.35 15.75L94.817 91.9l3.889 3.24 1.555-1.102.195-.777-1.75-2.917-14.453-26.118-15.425-26.572-6.87-11.018-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0 82.05 1.426l4.472 3.888 6.61 15.101 10.694 23.786 16.591 32.34 4.861 9.592 2.592 8.879.973 2.722h1.685v-1.556l1.36-18.211 2.528-22.36 2.463-28.776.843-8.1 4.018-9.722 7.971-5.25 6.222 2.981 5.12 7.324-.713 4.73-3.046 19.768-5.962 30.98-3.889 20.739h2.268l2.593-2.593 10.499-13.934 17.628-22.036 7.778-8.749 9.073-9.657 5.833-4.601h11.018l8.1 12.055-3.628 12.443-11.342 14.388-9.398 12.184-13.48 18.147-8.426 14.518.778 1.166 2.01-.194 30.46-6.481 16.462-2.982 19.637-3.37 8.88 4.148.971 4.213-3.5 8.62-20.998 5.184-24.628 4.926-36.682 8.685-.454.324.519.648 16.526 1.555 7.065.389h17.304l32.21 2.398 8.426 5.574 5.055 6.805-.843 5.184-12.962 6.611-17.498-4.148-40.83-9.721-14-3.5h-1.944v1.167l11.666 11.406 21.387 19.314 26.767 24.887 1.36 6.157-3.434 4.86-3.63-.518-23.526-17.693-9.073-7.972-20.545-17.304h-1.36v1.814l4.73 6.935 25.017 37.59 1.296 11.536-1.814 3.76-6.481 2.268-7.13-1.297-14.647-20.544-15.1-23.138-12.185-20.739-1.49.843-7.194 77.448-3.37 3.953-7.778 2.981-6.48-4.925-3.436-7.972 3.435-15.749 4.148-20.544 3.37-16.333 3.046-20.285 1.815-6.74-.13-.454-1.49.194-15.295 20.999-23.267 31.433-18.406 19.702-4.407 1.75-7.648-3.954.713-7.064 4.277-6.286 25.47-32.405 15.36-20.092 9.917-11.6-.065-1.686h-.583L44.07 198.125l-12.055 1.555-5.185-4.86.648-7.972 2.463-2.593 20.35-13.999-.064.065Z"/></svg>
@@ -0,0 +1 @@
1
+ <svg fill="#111" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Codex</title><path clip-rule="evenodd" d="M8.086.457a6.105 6.105 0 013.046-.415c1.333.153 2.521.72 3.564 1.7a.117.117 0 00.107.029c1.408-.346 2.762-.224 4.061.366l.063.03.154.076c1.357.703 2.33 1.77 2.918 3.198.278.679.418 1.388.421 2.126a5.655 5.655 0 01-.18 1.631.167.167 0 00.04.155 5.982 5.982 0 011.578 2.891c.385 1.901-.01 3.615-1.183 5.14l-.182.22a6.063 6.063 0 01-2.934 1.851.162.162 0 00-.108.102c-.255.736-.511 1.364-.987 1.992-1.199 1.582-2.962 2.462-4.948 2.451-1.583-.008-2.986-.587-4.21-1.736a.145.145 0 00-.14-.032c-.518.167-1.04.191-1.604.185a5.924 5.924 0 01-2.595-.622 6.058 6.058 0 01-2.146-1.781c-.203-.269-.404-.522-.551-.821a7.74 7.74 0 01-.495-1.283 6.11 6.11 0 01-.017-3.064.166.166 0 00.008-.074.115.115 0 00-.037-.064 5.958 5.958 0 01-1.38-2.202 5.196 5.196 0 01-.333-1.589 6.915 6.915 0 01.188-2.132c.45-1.484 1.309-2.648 2.577-3.493.282-.188.55-.334.802-.438.286-.12.573-.22.861-.304a.129.129 0 00.087-.087A6.016 6.016 0 015.635 2.31C6.315 1.464 7.132.846 8.086.457zm-.804 7.85a.848.848 0 00-1.473.842l1.694 2.965-1.688 2.848a.849.849 0 001.46.864l1.94-3.272a.849.849 0 00.007-.854l-1.94-3.393zm5.446 6.24a.849.849 0 000 1.695h4.848a.849.849 0 000-1.696h-4.848z"/></svg>
@@ -0,0 +1 @@
1
+ <svg fill="#fff" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Codex</title><path clip-rule="evenodd" d="M8.086.457a6.105 6.105 0 013.046-.415c1.333.153 2.521.72 3.564 1.7a.117.117 0 00.107.029c1.408-.346 2.762-.224 4.061.366l.063.03.154.076c1.357.703 2.33 1.77 2.918 3.198.278.679.418 1.388.421 2.126a5.655 5.655 0 01-.18 1.631.167.167 0 00.04.155 5.982 5.982 0 011.578 2.891c.385 1.901-.01 3.615-1.183 5.14l-.182.22a6.063 6.063 0 01-2.934 1.851.162.162 0 00-.108.102c-.255.736-.511 1.364-.987 1.992-1.199 1.582-2.962 2.462-4.948 2.451-1.583-.008-2.986-.587-4.21-1.736a.145.145 0 00-.14-.032c-.518.167-1.04.191-1.604.185a5.924 5.924 0 01-2.595-.622 6.058 6.058 0 01-2.146-1.781c-.203-.269-.404-.522-.551-.821a7.74 7.74 0 01-.495-1.283 6.11 6.11 0 01-.017-3.064.166.166 0 00.008-.074.115.115 0 00-.037-.064 5.958 5.958 0 01-1.38-2.202 5.196 5.196 0 01-.333-1.589 6.915 6.915 0 01.188-2.132c.45-1.484 1.309-2.648 2.577-3.493.282-.188.55-.334.802-.438.286-.12.573-.22.861-.304a.129.129 0 00.087-.087A6.016 6.016 0 015.635 2.31C6.315 1.464 7.132.846 8.086.457zm-.804 7.85a.848.848 0 00-1.473.842l1.694 2.965-1.688 2.848a.849.849 0 001.46.864l1.94-3.272a.849.849 0 00.007-.854l-1.94-3.393zm5.446 6.24a.849.849 0 000 1.695h4.848a.849.849 0 000-1.696h-4.848z"/></svg>
@@ -0,0 +1,7 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
2
+ <g fill="none" stroke="#ff6b22" stroke-linecap="round" stroke-width="5">
3
+ <path d="M32 5v12M32 47v12M5 32h12M47 32h12"/>
4
+ <path d="m13 13 8.5 8.5M42.5 42.5 51 51M51 13l-8.5 8.5M21.5 42.5 13 51"/>
5
+ </g>
6
+ <circle cx="32" cy="32" r="4" fill="#ff6b22"/>
7
+ </svg>
@@ -0,0 +1,3 @@
1
+ <svg viewBox="0 0 98 96" width="24" height="24" xmlns="http://www.w3.org/2000/svg">
2
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#2088FF"/>
3
+ </svg>
@@ -0,0 +1,3 @@
1
+ <svg viewBox="0 0 24 24" width="24" height="24" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z" fill="#22D3EE"/>
3
+ </svg>