oc-codex-multi-auth 6.21.0 → 6.22.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/LICENSE +21 -21
- package/README.md +729 -571
- package/assets/icon.svg +7 -7
- package/assets/opencode-logo-ornate-dark.svg +18 -18
- package/assets/readme-hero.svg +31 -31
- package/config/README.md +177 -177
- package/config/minimal-opencode.json +14 -14
- package/config/opencode-legacy.json +1496 -1496
- package/config/opencode-modern.json +555 -555
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +291 -53
- package/dist/index.js.map +1 -1
- package/dist/lib/account-display.d.ts +46 -0
- package/dist/lib/account-display.d.ts.map +1 -1
- package/dist/lib/account-display.js +260 -0
- package/dist/lib/account-display.js.map +1 -1
- package/dist/lib/accounts.d.ts +4 -0
- package/dist/lib/accounts.d.ts.map +1 -1
- package/dist/lib/accounts.js +20 -15
- package/dist/lib/accounts.js.map +1 -1
- package/dist/lib/auth/login-runner.d.ts.map +1 -1
- package/dist/lib/auth/login-runner.js +79 -2
- package/dist/lib/auth/login-runner.js.map +1 -1
- package/dist/lib/auto-update-checker.d.ts +17 -1
- package/dist/lib/auto-update-checker.d.ts.map +1 -1
- package/dist/lib/auto-update-checker.js +49 -3
- package/dist/lib/auto-update-checker.js.map +1 -1
- package/dist/lib/cli.d.ts +1 -0
- package/dist/lib/cli.d.ts.map +1 -1
- package/dist/lib/cli.js +9 -3
- package/dist/lib/cli.js.map +1 -1
- package/dist/lib/codex-usage.d.ts +4 -3
- package/dist/lib/codex-usage.d.ts.map +1 -1
- package/dist/lib/codex-usage.js +13 -11
- package/dist/lib/codex-usage.js.map +1 -1
- package/dist/lib/config.d.ts +75 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +152 -1
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/context-overflow.js +10 -10
- package/dist/lib/desktop-notifications.js +6 -6
- package/dist/lib/oauth-success.js +202 -202
- package/dist/lib/plan-allotment.d.ts +72 -0
- package/dist/lib/plan-allotment.d.ts.map +1 -0
- package/dist/lib/plan-allotment.js +142 -0
- package/dist/lib/plan-allotment.js.map +1 -0
- package/dist/lib/plugin-origin.d.ts +77 -0
- package/dist/lib/plugin-origin.d.ts.map +1 -0
- package/dist/lib/plugin-origin.js +301 -0
- package/dist/lib/plugin-origin.js.map +1 -0
- package/dist/lib/prompts/codex-opencode-bridge.js +67 -67
- package/dist/lib/prompts/codex.js +75 -75
- package/dist/lib/quota-display.d.ts +40 -0
- package/dist/lib/quota-display.d.ts.map +1 -0
- package/dist/lib/quota-display.js +45 -0
- package/dist/lib/quota-display.js.map +1 -0
- package/dist/lib/quota-notifications.d.ts +2 -1
- package/dist/lib/quota-notifications.d.ts.map +1 -1
- package/dist/lib/quota-notifications.js +34 -14
- package/dist/lib/quota-notifications.js.map +1 -1
- package/dist/lib/quota-overview.d.ts +253 -0
- package/dist/lib/quota-overview.d.ts.map +1 -0
- package/dist/lib/quota-overview.js +707 -0
- package/dist/lib/quota-overview.js.map +1 -0
- package/dist/lib/request/retry-budget.d.ts +26 -0
- package/dist/lib/request/retry-budget.d.ts.map +1 -1
- package/dist/lib/request/retry-budget.js +43 -0
- package/dist/lib/request/retry-budget.js.map +1 -1
- package/dist/lib/schemas.d.ts +67 -0
- package/dist/lib/schemas.d.ts.map +1 -1
- package/dist/lib/schemas.js +53 -0
- package/dist/lib/schemas.js.map +1 -1
- package/dist/lib/storage/backup.d.ts +33 -0
- package/dist/lib/storage/backup.d.ts.map +1 -1
- package/dist/lib/storage/backup.js +43 -7
- package/dist/lib/storage/backup.js.map +1 -1
- package/dist/lib/storage/credential-snapshots.d.ts +78 -0
- package/dist/lib/storage/credential-snapshots.d.ts.map +1 -0
- package/dist/lib/storage/credential-snapshots.js +302 -0
- package/dist/lib/storage/credential-snapshots.js.map +1 -0
- package/dist/lib/storage/export-import.d.ts.map +1 -1
- package/dist/lib/storage/export-import.js +7 -0
- package/dist/lib/storage/export-import.js.map +1 -1
- package/dist/lib/storage/flagged.d.ts.map +1 -1
- package/dist/lib/storage/flagged.js +23 -1
- package/dist/lib/storage/flagged.js.map +1 -1
- package/dist/lib/storage/load-save.d.ts +4 -0
- package/dist/lib/storage/load-save.d.ts.map +1 -1
- package/dist/lib/storage/load-save.js +50 -0
- package/dist/lib/storage/load-save.js.map +1 -1
- package/dist/lib/storage/paths.d.ts +1 -0
- package/dist/lib/storage/paths.d.ts.map +1 -1
- package/dist/lib/storage/paths.js +1 -1
- package/dist/lib/storage/paths.js.map +1 -1
- package/dist/lib/storage/test-home-guard.d.ts +35 -0
- package/dist/lib/storage/test-home-guard.d.ts.map +1 -0
- package/dist/lib/storage/test-home-guard.js +59 -0
- package/dist/lib/storage/test-home-guard.js.map +1 -0
- package/dist/lib/tools/codex-dashboard.d.ts.map +1 -1
- package/dist/lib/tools/codex-dashboard.js +3 -2
- package/dist/lib/tools/codex-dashboard.js.map +1 -1
- package/dist/lib/tools/codex-doctor.d.ts.map +1 -1
- package/dist/lib/tools/codex-doctor.js +16 -0
- package/dist/lib/tools/codex-doctor.js.map +1 -1
- package/dist/lib/tools/codex-health.d.ts.map +1 -1
- package/dist/lib/tools/codex-health.js +10 -2
- package/dist/lib/tools/codex-health.js.map +1 -1
- package/dist/lib/tools/codex-label.d.ts.map +1 -1
- package/dist/lib/tools/codex-label.js +1 -0
- package/dist/lib/tools/codex-label.js.map +1 -1
- package/dist/lib/tools/codex-limits.d.ts.map +1 -1
- package/dist/lib/tools/codex-limits.js +13 -9
- package/dist/lib/tools/codex-limits.js.map +1 -1
- package/dist/lib/tools/codex-list.d.ts.map +1 -1
- package/dist/lib/tools/codex-list.js +28 -3
- package/dist/lib/tools/codex-list.js.map +1 -1
- package/dist/lib/tools/codex-note.d.ts.map +1 -1
- package/dist/lib/tools/codex-note.js +4 -1
- package/dist/lib/tools/codex-note.js.map +1 -1
- package/dist/lib/tools/codex-pool.d.ts.map +1 -1
- package/dist/lib/tools/codex-pool.js +6 -2
- package/dist/lib/tools/codex-pool.js.map +1 -1
- package/dist/lib/tools/codex-refresh.d.ts.map +1 -1
- package/dist/lib/tools/codex-refresh.js +4 -1
- package/dist/lib/tools/codex-refresh.js.map +1 -1
- package/dist/lib/tools/codex-remove.d.ts.map +1 -1
- package/dist/lib/tools/codex-remove.js +1 -0
- package/dist/lib/tools/codex-remove.js.map +1 -1
- package/dist/lib/tools/codex-reset.d.ts.map +1 -1
- package/dist/lib/tools/codex-reset.js +13 -7
- package/dist/lib/tools/codex-reset.js.map +1 -1
- package/dist/lib/tools/codex-status.d.ts.map +1 -1
- package/dist/lib/tools/codex-status.js +25 -2
- package/dist/lib/tools/codex-status.js.map +1 -1
- package/dist/lib/tools/codex-switch.d.ts.map +1 -1
- package/dist/lib/tools/codex-switch.js +1 -0
- package/dist/lib/tools/codex-switch.js.map +1 -1
- package/dist/lib/tools/codex-tag.d.ts.map +1 -1
- package/dist/lib/tools/codex-tag.js +4 -1
- package/dist/lib/tools/codex-tag.js.map +1 -1
- package/dist/lib/tools/codex-warm.d.ts.map +1 -1
- package/dist/lib/tools/codex-warm.js +1 -0
- package/dist/lib/tools/codex-warm.js.map +1 -1
- package/dist/lib/tools/index.d.ts +9 -0
- package/dist/lib/tools/index.d.ts.map +1 -1
- package/dist/lib/tools/index.js.map +1 -1
- package/dist/lib/tui-quota-cache.d.ts +35 -0
- package/dist/lib/tui-quota-cache.d.ts.map +1 -1
- package/dist/lib/tui-quota-cache.js +76 -11
- package/dist/lib/tui-quota-cache.js.map +1 -1
- package/dist/lib/tui-quota-overview.d.ts +55 -0
- package/dist/lib/tui-quota-overview.d.ts.map +1 -0
- package/dist/lib/tui-quota-overview.js +209 -0
- package/dist/lib/tui-quota-overview.js.map +1 -0
- package/dist/lib/tui-status.d.ts +55 -0
- package/dist/lib/tui-status.d.ts.map +1 -1
- package/dist/lib/tui-status.js +155 -14
- package/dist/lib/tui-status.js.map +1 -1
- package/dist/lib/ui/auth-menu.d.ts +2 -0
- package/dist/lib/ui/auth-menu.d.ts.map +1 -1
- package/dist/lib/ui/auth-menu.js +10 -6
- package/dist/lib/ui/auth-menu.js.map +1 -1
- package/dist/tui.d.ts +55 -0
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +511 -29
- package/dist/tui.js.map +1 -1
- package/package.json +155 -155
- package/scripts/audit-dev-allowlist.js +114 -114
- package/scripts/clean-dist.js +27 -27
- package/scripts/copy-oauth-success.js +47 -47
- package/scripts/install-oc-codex-multi-auth-core.js +2003 -1499
- package/scripts/install-oc-codex-multi-auth.js +37 -37
- package/scripts/test-all-models.sh +260 -260
- package/scripts/validate-model-map.sh +97 -97
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One constant line describing the whole account pool.
|
|
3
|
+
*
|
|
4
|
+
* The prompt status line names whichever account served the most recent
|
|
5
|
+
* request, so on a pool of several accounts it changes identity as rotation
|
|
6
|
+
* moves - and a reader who wants to know where the pool stands has to watch it
|
|
7
|
+
* long enough to see every account go past. This module renders the pool
|
|
8
|
+
* instead: every account at once, in a fixed order, so the line only changes
|
|
9
|
+
* when the underlying quota does.
|
|
10
|
+
*
|
|
11
|
+
* ```text
|
|
12
|
+
* 24%: #1 5x 13%, #2 20x 100% 3d 1r, #3 1x 12%
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* The leading figure is the pool total, and it is a WEIGHTED mean rather than
|
|
16
|
+
* a plain one. A Pro seat spent to 50% has given up twenty times the capacity
|
|
17
|
+
* a Business Standard seat does at 50%, so averaging the percentages
|
|
18
|
+
* unweighted describes a pool nobody has; `lib/plan-allotment.ts` supplies the
|
|
19
|
+
* per-plan ratio the mean is taken over.
|
|
20
|
+
*
|
|
21
|
+
* Percentages follow `quotaDisplay` like every other surface, so the same pool
|
|
22
|
+
* reads `24%` as headroom or `76%` as consumption. Only the wording changes:
|
|
23
|
+
* every decision here - which window governs an account, which account is
|
|
24
|
+
* closest to recovering, whether a reset time is worth the characters - stays
|
|
25
|
+
* keyed on the percentage remaining.
|
|
26
|
+
*
|
|
27
|
+
* Everything below is pure string work over already-gathered readings, so the
|
|
28
|
+
* whole rendering can be exercised without a network, a clock or a terminal.
|
|
29
|
+
*/
|
|
30
|
+
import { maskEmailForDisplay } from "./account-display.js";
|
|
31
|
+
import { formatPlanMultiplier, getPlanWeight } from "./plan-allotment.js";
|
|
32
|
+
import { formatQuotaPercent, toQuotaDisplayPercent, } from "./quota-display.js";
|
|
33
|
+
const MS_PER_MINUTE = 60_000;
|
|
34
|
+
const MS_PER_HOUR = 60 * MS_PER_MINUTE;
|
|
35
|
+
const MS_PER_DAY = 24 * MS_PER_HOUR;
|
|
36
|
+
/**
|
|
37
|
+
* Under `resetTimes: "low"`, only an account at or below this headroom gets
|
|
38
|
+
* its reset time printed.
|
|
39
|
+
*
|
|
40
|
+
* Every account has a reset, and printing all of them triples the length of
|
|
41
|
+
* the line to say "this account you are not waiting on recovers at some point
|
|
42
|
+
* too". The threshold matches the one the single-account status line already
|
|
43
|
+
* uses to decide the same question, so an account near exhaustion reads the
|
|
44
|
+
* same way in either mode. `resetTimes: "always"` opts out of the threshold,
|
|
45
|
+
* because 90% spent with an hour to go and 90% spent with six days to go are
|
|
46
|
+
* not the same situation.
|
|
47
|
+
*/
|
|
48
|
+
export const OVERVIEW_RESET_LEFT_PERCENT = 25;
|
|
49
|
+
/** Smallest pool-total movement worth spending characters on. */
|
|
50
|
+
const MIN_RECOVERY_DELTA_PERCENT = 1;
|
|
51
|
+
function isPercent(value) {
|
|
52
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Render a duration the way a countdown reads: the largest unit that fits,
|
|
56
|
+
* floored, so `2d` never claims more time remains than actually does. A gap
|
|
57
|
+
* under a minute still reads `1m` rather than `0m`, because a reset that has
|
|
58
|
+
* not happened yet is not zero away.
|
|
59
|
+
*/
|
|
60
|
+
export function formatCompactDuration(ms) {
|
|
61
|
+
if (!Number.isFinite(ms) || ms <= 0)
|
|
62
|
+
return undefined;
|
|
63
|
+
if (ms >= MS_PER_DAY)
|
|
64
|
+
return `${Math.floor(ms / MS_PER_DAY)}d`;
|
|
65
|
+
if (ms >= MS_PER_HOUR)
|
|
66
|
+
return `${Math.floor(ms / MS_PER_HOUR)}h`;
|
|
67
|
+
return `${Math.max(1, Math.floor(ms / MS_PER_MINUTE))}m`;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The window that decides what an account can still do.
|
|
71
|
+
*
|
|
72
|
+
* An account reports several windows at once - typically a 5-hour and a weekly
|
|
73
|
+
* one - and the one with the least headroom is the one that stops a request,
|
|
74
|
+
* so it is the one the account is described by. On a tie the window that
|
|
75
|
+
* blocks for longer governs: two windows both fully spent are not equally
|
|
76
|
+
* costly when one returns in four hours and the other in three days.
|
|
77
|
+
*/
|
|
78
|
+
export function resolveGoverningWindow(account) {
|
|
79
|
+
let governing;
|
|
80
|
+
for (const window of account.windows) {
|
|
81
|
+
if (!isPercent(window.leftPercent))
|
|
82
|
+
continue;
|
|
83
|
+
if (!governing) {
|
|
84
|
+
governing = window;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const governingLeft = governing.leftPercent ?? 100;
|
|
88
|
+
if (window.leftPercent < governingLeft) {
|
|
89
|
+
governing = window;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (window.leftPercent === governingLeft &&
|
|
93
|
+
isPercent(window.resetAtMs) &&
|
|
94
|
+
(!isPercent(governing.resetAtMs) || window.resetAtMs > governing.resetAtMs)) {
|
|
95
|
+
governing = window;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return governing;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Weighted mean headroom across the pool, or `undefined` when no account
|
|
102
|
+
* reported a readable window.
|
|
103
|
+
*
|
|
104
|
+
* Accounts with no readable window are left out rather than counted as full:
|
|
105
|
+
* a quota we could not read is not capacity we know we have.
|
|
106
|
+
*/
|
|
107
|
+
export function computeWeightedLeftPercent(accounts) {
|
|
108
|
+
let weighted = 0;
|
|
109
|
+
let totalWeight = 0;
|
|
110
|
+
for (const account of accounts) {
|
|
111
|
+
const governing = resolveGoverningWindow(account);
|
|
112
|
+
if (!governing || !isPercent(governing.leftPercent))
|
|
113
|
+
continue;
|
|
114
|
+
const weight = getPlanWeight(account.planType);
|
|
115
|
+
if (!Number.isFinite(weight) || weight <= 0)
|
|
116
|
+
continue;
|
|
117
|
+
weighted += weight * governing.leftPercent;
|
|
118
|
+
totalWeight += weight;
|
|
119
|
+
}
|
|
120
|
+
return totalWeight > 0 ? Math.round(weighted / totalWeight) : undefined;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* What the pool the percentage is taken over adds up to, in 1x seats.
|
|
124
|
+
*
|
|
125
|
+
* Deliberately the same sum {@link computeWeightedLeftPercent} divides by, and
|
|
126
|
+
* over the same accounts, so `66% of 65x` is one statement rather than two
|
|
127
|
+
* that can disagree. An account whose plan states no ratio therefore
|
|
128
|
+
* contributes its fallback weight here exactly as it does to the mean.
|
|
129
|
+
*/
|
|
130
|
+
export function computePoolAllotment(accounts) {
|
|
131
|
+
let total = 0;
|
|
132
|
+
for (const account of accounts) {
|
|
133
|
+
const governing = resolveGoverningWindow(account);
|
|
134
|
+
if (!governing || !isPercent(governing.leftPercent))
|
|
135
|
+
continue;
|
|
136
|
+
const weight = getPlanWeight(account.planType);
|
|
137
|
+
if (!Number.isFinite(weight) || weight <= 0)
|
|
138
|
+
continue;
|
|
139
|
+
total += weight;
|
|
140
|
+
}
|
|
141
|
+
return total > 0 ? total : undefined;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* The next moment the pool gets capacity back, and how much.
|
|
145
|
+
*
|
|
146
|
+
* Only the window that actually resets is refilled, and the account's
|
|
147
|
+
* governing window is then resolved again: an account whose 5-hour window
|
|
148
|
+
* resets while its weekly window is still spent gains nothing, and reporting
|
|
149
|
+
* the 5-hour refill as pool recovery would promise headroom that does not
|
|
150
|
+
* arrive. Movement below one point is dropped rather than rendered as `+0%`.
|
|
151
|
+
*/
|
|
152
|
+
export function resolveQuotaOverviewRecovery(accounts, now = Date.now()) {
|
|
153
|
+
const current = computeWeightedLeftPercent(accounts);
|
|
154
|
+
if (current === undefined)
|
|
155
|
+
return undefined;
|
|
156
|
+
let earliest;
|
|
157
|
+
for (const account of accounts) {
|
|
158
|
+
for (const window of account.windows) {
|
|
159
|
+
if (!isPercent(window.leftPercent) || window.leftPercent >= 100)
|
|
160
|
+
continue;
|
|
161
|
+
const resetAtMs = window.resetAtMs;
|
|
162
|
+
if (!isPercent(resetAtMs) || resetAtMs <= now)
|
|
163
|
+
continue;
|
|
164
|
+
if (earliest === undefined || resetAtMs < earliest)
|
|
165
|
+
earliest = resetAtMs;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (earliest === undefined)
|
|
169
|
+
return undefined;
|
|
170
|
+
const refilled = accounts.map((account) => ({
|
|
171
|
+
...account,
|
|
172
|
+
windows: account.windows.map((window) => isPercent(window.resetAtMs) && window.resetAtMs <= earliest
|
|
173
|
+
? { ...window, leftPercent: 100 }
|
|
174
|
+
: window),
|
|
175
|
+
}));
|
|
176
|
+
const recovered = computeWeightedLeftPercent(refilled);
|
|
177
|
+
if (recovered === undefined)
|
|
178
|
+
return undefined;
|
|
179
|
+
const deltaPercent = recovered - current;
|
|
180
|
+
if (deltaPercent < MIN_RECOVERY_DELTA_PERCENT)
|
|
181
|
+
return undefined;
|
|
182
|
+
return { deltaPercent, atMs: earliest };
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Whether nothing in the pool has capacity left.
|
|
186
|
+
*
|
|
187
|
+
* This is the condition the reset-credit line exists for: while any account
|
|
188
|
+
* can still serve a request, which one recovers when is a detail, and once
|
|
189
|
+
* none can it is the only question left. Accounts whose quota could not be
|
|
190
|
+
* read do not count either way - an unknown reading is not evidence of
|
|
191
|
+
* exhaustion, but it is not capacity either, so a pool of nothing but
|
|
192
|
+
* unreadable accounts is reported as not spent rather than as dead.
|
|
193
|
+
*/
|
|
194
|
+
export function isPoolFullySpent(accounts) {
|
|
195
|
+
let readable = 0;
|
|
196
|
+
for (const account of accounts) {
|
|
197
|
+
const governing = resolveGoverningWindow(account);
|
|
198
|
+
if (!governing || !isPercent(governing.leftPercent))
|
|
199
|
+
continue;
|
|
200
|
+
readable += 1;
|
|
201
|
+
if (governing.leftPercent > 0)
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
return readable > 0;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Sort key for the reset-time orders.
|
|
208
|
+
*
|
|
209
|
+
* An account with no known reset sorts after every account that has one, in
|
|
210
|
+
* both directions. Not knowing when something returns is a different statement
|
|
211
|
+
* from knowing it returns soon, and a different statement from knowing it
|
|
212
|
+
* returns last.
|
|
213
|
+
*/
|
|
214
|
+
function governingResetAtMs(account) {
|
|
215
|
+
const governing = resolveGoverningWindow(account);
|
|
216
|
+
return governing && isPercent(governing.resetAtMs)
|
|
217
|
+
? governing.resetAtMs
|
|
218
|
+
: undefined;
|
|
219
|
+
}
|
|
220
|
+
function governingLeftPercent(account) {
|
|
221
|
+
const governing = resolveGoverningWindow(account);
|
|
222
|
+
return governing && isPercent(governing.leftPercent)
|
|
223
|
+
? governing.leftPercent
|
|
224
|
+
: undefined;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Arrange the accounts for display.
|
|
228
|
+
*
|
|
229
|
+
* Every comparison falls back to the account number, so two accounts reading
|
|
230
|
+
* the same percentage never trade places between renders. An order that let
|
|
231
|
+
* them would reintroduce exactly the movement this mode exists to remove.
|
|
232
|
+
*/
|
|
233
|
+
export function orderOverviewAccounts(accounts, order) {
|
|
234
|
+
const sorted = [...accounts];
|
|
235
|
+
if (order === "most-used" || order === "least-used") {
|
|
236
|
+
const direction = order === "most-used" ? 1 : -1;
|
|
237
|
+
return sorted.sort((left, right) => {
|
|
238
|
+
const leftPercent = governingLeftPercent(left);
|
|
239
|
+
const rightPercent = governingLeftPercent(right);
|
|
240
|
+
if (leftPercent === undefined && rightPercent === undefined) {
|
|
241
|
+
return left.index - right.index;
|
|
242
|
+
}
|
|
243
|
+
if (leftPercent === undefined)
|
|
244
|
+
return 1;
|
|
245
|
+
if (rightPercent === undefined)
|
|
246
|
+
return -1;
|
|
247
|
+
if (leftPercent !== rightPercent) {
|
|
248
|
+
return direction * (leftPercent - rightPercent);
|
|
249
|
+
}
|
|
250
|
+
return left.index - right.index;
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
if (order === "renewing-earliest" || order === "renewing-latest") {
|
|
254
|
+
const direction = order === "renewing-earliest" ? 1 : -1;
|
|
255
|
+
return sorted.sort((left, right) => {
|
|
256
|
+
const leftReset = governingResetAtMs(left);
|
|
257
|
+
const rightReset = governingResetAtMs(right);
|
|
258
|
+
if (leftReset === undefined && rightReset === undefined) {
|
|
259
|
+
return left.index - right.index;
|
|
260
|
+
}
|
|
261
|
+
if (leftReset === undefined)
|
|
262
|
+
return 1;
|
|
263
|
+
if (rightReset === undefined)
|
|
264
|
+
return -1;
|
|
265
|
+
if (leftReset !== rightReset)
|
|
266
|
+
return direction * (leftReset - rightReset);
|
|
267
|
+
return left.index - right.index;
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
// `number` and anything unrecognized: an order nobody asked for must not
|
|
271
|
+
// silently become one of the sorted ones, which would move accounts around
|
|
272
|
+
// under a reader who configured nothing.
|
|
273
|
+
return sorted.sort((left, right) => left.index - right.index);
|
|
274
|
+
}
|
|
275
|
+
const EMAIL_LIKE = /^[^\s@]+@[^\s@]+$/;
|
|
276
|
+
/**
|
|
277
|
+
* The local part of an email, which is what a person calls the account.
|
|
278
|
+
*
|
|
279
|
+
* `damian@nowaker.net` -> `damian`. Masking is applied to the local part
|
|
280
|
+
* rather than to the whole address, because the domain is what
|
|
281
|
+
* {@link maskEmailForDisplay} keeps and there is no room for it here.
|
|
282
|
+
*/
|
|
283
|
+
function formatEmailLocalPart(email, maskEmail) {
|
|
284
|
+
const trimmed = email.trim();
|
|
285
|
+
if (!trimmed)
|
|
286
|
+
return undefined;
|
|
287
|
+
const local = trimmed.split("@")[0]?.trim();
|
|
288
|
+
if (!local)
|
|
289
|
+
return undefined;
|
|
290
|
+
if (!maskEmail)
|
|
291
|
+
return local;
|
|
292
|
+
return `${Array.from(local).slice(0, 2).join("")}***`;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* What this account is called on the line.
|
|
296
|
+
*
|
|
297
|
+
* Under `label` a user-set label wins, because it is the one name the user
|
|
298
|
+
* chose; an account that only knows its email falls back to that email's local
|
|
299
|
+
* part, and an account with neither falls back to its number rather than
|
|
300
|
+
* rendering nothing - a nameless segment in a named line reads as a missing
|
|
301
|
+
* account.
|
|
302
|
+
*/
|
|
303
|
+
export function resolveAccountName(account, names, maskEmail = false) {
|
|
304
|
+
if (names === "none")
|
|
305
|
+
return undefined;
|
|
306
|
+
if (names === "number")
|
|
307
|
+
return `#${account.index}`;
|
|
308
|
+
const label = account.label?.trim();
|
|
309
|
+
if (label && !EMAIL_LIKE.test(label))
|
|
310
|
+
return label;
|
|
311
|
+
const email = label && EMAIL_LIKE.test(label) ? label : account.email;
|
|
312
|
+
const local = email ? formatEmailLocalPart(email, maskEmail) : undefined;
|
|
313
|
+
return local ?? `#${account.index}`;
|
|
314
|
+
}
|
|
315
|
+
/** The full email for the reset-credit line, masked when asked. */
|
|
316
|
+
function resolveAccountEmail(account, maskEmail) {
|
|
317
|
+
const label = account.label?.trim();
|
|
318
|
+
const email = account.email?.trim() || (label && EMAIL_LIKE.test(label) ? label : undefined);
|
|
319
|
+
if (!email)
|
|
320
|
+
return undefined;
|
|
321
|
+
return maskEmail ? maskEmailForDisplay(email) : email;
|
|
322
|
+
}
|
|
323
|
+
function resolveResetCredits(account) {
|
|
324
|
+
const credits = account.resetCredits;
|
|
325
|
+
return typeof credits === "number" && Number.isFinite(credits) && credits > 0
|
|
326
|
+
? Math.trunc(credits)
|
|
327
|
+
: 0;
|
|
328
|
+
}
|
|
329
|
+
function shouldPrintReset(leftPercent, resetTimes) {
|
|
330
|
+
if (resetTimes === "never")
|
|
331
|
+
return false;
|
|
332
|
+
if (resetTimes === "always")
|
|
333
|
+
return true;
|
|
334
|
+
return leftPercent <= OVERVIEW_RESET_LEFT_PERCENT;
|
|
335
|
+
}
|
|
336
|
+
/** The part of a segment that is not the account's name or its percentage. */
|
|
337
|
+
function formatAccountAnnotations(account, governing, options) {
|
|
338
|
+
const parts = [];
|
|
339
|
+
const leftPercent = governing.leftPercent;
|
|
340
|
+
if (isPercent(leftPercent) &&
|
|
341
|
+
shouldPrintReset(leftPercent, options.resetTimes) &&
|
|
342
|
+
isPercent(governing.resetAtMs)) {
|
|
343
|
+
const reset = formatCompactDuration(governing.resetAtMs - options.now);
|
|
344
|
+
if (reset)
|
|
345
|
+
parts.push(reset);
|
|
346
|
+
}
|
|
347
|
+
if (options.resetCredits) {
|
|
348
|
+
const credits = resolveResetCredits(account);
|
|
349
|
+
if (credits > 0)
|
|
350
|
+
parts.push(`${credits}r`);
|
|
351
|
+
}
|
|
352
|
+
return parts;
|
|
353
|
+
}
|
|
354
|
+
function formatAccountSegment(account, options) {
|
|
355
|
+
const governing = resolveGoverningWindow(account);
|
|
356
|
+
if (!governing || !isPercent(governing.leftPercent))
|
|
357
|
+
return undefined;
|
|
358
|
+
const parts = [];
|
|
359
|
+
const name = resolveAccountName(account, options.names, options.maskEmail);
|
|
360
|
+
if (name)
|
|
361
|
+
parts.push(name);
|
|
362
|
+
if (options.multipliers) {
|
|
363
|
+
const multiplier = formatPlanMultiplier(account.planType);
|
|
364
|
+
if (multiplier)
|
|
365
|
+
parts.push(multiplier);
|
|
366
|
+
}
|
|
367
|
+
parts.push(formatQuotaPercent(governing.leftPercent, options.mode));
|
|
368
|
+
parts.push(...formatAccountAnnotations(account, governing, options));
|
|
369
|
+
return parts.join(" ");
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Accounts reading the same percentage, collapsed into one segment.
|
|
373
|
+
*
|
|
374
|
+
* On a pool where several accounts are fully spent, `100% 3d, 100% 4d,
|
|
375
|
+
* 100% 5d` spends two thirds of its characters repeating a number that is the
|
|
376
|
+
* same every time. Grouping prints it once and keeps what differs:
|
|
377
|
+
*
|
|
378
|
+
* ```text
|
|
379
|
+
* 100% 3d 1r 4d 5d
|
|
380
|
+
* ```
|
|
381
|
+
*
|
|
382
|
+
* The group's size is stated explicitly - `100% x3 3d` - only when the
|
|
383
|
+
* annotations do not already reveal it, so a group of three accounts where one
|
|
384
|
+
* has no reset time to print cannot read as a group of two. A group of one is
|
|
385
|
+
* never counted, because there is nothing to count.
|
|
386
|
+
*
|
|
387
|
+
* Grouping discards identity by construction, so `names` has no effect here.
|
|
388
|
+
*/
|
|
389
|
+
function formatAggregateSegments(accounts, options) {
|
|
390
|
+
const groups = new Map();
|
|
391
|
+
for (const account of accounts) {
|
|
392
|
+
const governing = resolveGoverningWindow(account);
|
|
393
|
+
if (!governing || !isPercent(governing.leftPercent))
|
|
394
|
+
continue;
|
|
395
|
+
const percent = formatQuotaPercent(governing.leftPercent, options.mode);
|
|
396
|
+
const group = groups.get(percent) ?? {
|
|
397
|
+
percent,
|
|
398
|
+
size: 0,
|
|
399
|
+
annotations: [],
|
|
400
|
+
};
|
|
401
|
+
group.size += 1;
|
|
402
|
+
const annotations = formatAccountAnnotations(account, governing, options);
|
|
403
|
+
if (annotations.length > 0)
|
|
404
|
+
group.annotations.push(annotations.join(" "));
|
|
405
|
+
groups.set(percent, group);
|
|
406
|
+
}
|
|
407
|
+
return [...groups.values()].map((group) => {
|
|
408
|
+
const parts = [group.percent];
|
|
409
|
+
if (group.size > 1 && group.annotations.length < group.size) {
|
|
410
|
+
parts.push(`x${group.size}`);
|
|
411
|
+
}
|
|
412
|
+
parts.push(...group.annotations);
|
|
413
|
+
return parts.join(" ");
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* `+12% in 3d` / `-12% 3d`.
|
|
418
|
+
*
|
|
419
|
+
* The sign describes the direction the number beside it moves, not the
|
|
420
|
+
* direction of the user's fortunes: under `used` the pool total falls as
|
|
421
|
+
* capacity returns, and a `+` there would contradict the figure it annotates.
|
|
422
|
+
* The word `in` is the first thing dropped when the line is short, because it
|
|
423
|
+
* is the only part of the clause a reader can supply themselves.
|
|
424
|
+
*/
|
|
425
|
+
function formatRecovery(recovery, options) {
|
|
426
|
+
const at = formatCompactDuration(recovery.atMs - options.now);
|
|
427
|
+
if (!at)
|
|
428
|
+
return undefined;
|
|
429
|
+
const sign = options.mode === "used" ? "-" : "+";
|
|
430
|
+
return `${sign}${recovery.deltaPercent}% ${options.words ? "in " : ""}${at}`;
|
|
431
|
+
}
|
|
432
|
+
function formatAccountCount(count, style) {
|
|
433
|
+
if (style === "bare")
|
|
434
|
+
return `${count}`;
|
|
435
|
+
if (style === "short")
|
|
436
|
+
return `${count} acct.`;
|
|
437
|
+
return `${count} account${count === 1 ? "" : "s"}`;
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Every annotation level to try, most informative first.
|
|
441
|
+
*
|
|
442
|
+
* One dimension is given up per rung and never restored within the ladder, so
|
|
443
|
+
* each rung is strictly shorter than the one above it. The order is by what a
|
|
444
|
+
* reader loses: the plan badge says nothing the account's own numbers do not,
|
|
445
|
+
* a long label can be replaced by the number that selects the same account,
|
|
446
|
+
* banked resets only matter once something is spent, and a reset countdown on
|
|
447
|
+
* a healthy account is the detail `resetTimes: "always"` opted into.
|
|
448
|
+
*
|
|
449
|
+
* Dropping the account's name entirely is the last rung, and it is offered
|
|
450
|
+
* only when position still identifies an account: under a sorted order, or
|
|
451
|
+
* with any account missing from the line, `39%, 8%, 91%` names nothing at all
|
|
452
|
+
* and a reader would attach those figures to the wrong seats.
|
|
453
|
+
*/
|
|
454
|
+
function annotationRungs(options, positionsAreComplete) {
|
|
455
|
+
const rungs = [];
|
|
456
|
+
let current = {
|
|
457
|
+
names: options.names,
|
|
458
|
+
multipliers: options.multipliers,
|
|
459
|
+
resetTimes: options.resetTimes,
|
|
460
|
+
resetCredits: options.resetCredits,
|
|
461
|
+
};
|
|
462
|
+
rungs.push(current);
|
|
463
|
+
const step = (next) => {
|
|
464
|
+
current = { ...current, ...next };
|
|
465
|
+
rungs.push(current);
|
|
466
|
+
};
|
|
467
|
+
if (current.multipliers)
|
|
468
|
+
step({ multipliers: false });
|
|
469
|
+
if (current.names === "label")
|
|
470
|
+
step({ names: "number" });
|
|
471
|
+
if (current.resetCredits)
|
|
472
|
+
step({ resetCredits: false });
|
|
473
|
+
if (current.resetTimes === "always")
|
|
474
|
+
step({ resetTimes: "low" });
|
|
475
|
+
if (current.resetTimes !== "never")
|
|
476
|
+
step({ resetTimes: "never" });
|
|
477
|
+
if (current.names !== "none" && positionsAreComplete)
|
|
478
|
+
step({ names: "none" });
|
|
479
|
+
return rungs;
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Every rendering of this pool, longest first.
|
|
483
|
+
*
|
|
484
|
+
* The caller takes the first that fits its width. Detail is dropped in the
|
|
485
|
+
* order that costs a reader the least: the recovery clause and then the
|
|
486
|
+
* annotations (badges, banked resets) that sit beside a figure which stays
|
|
487
|
+
* either way, then the per-account breakdown, leaving the pool total - the one
|
|
488
|
+
* thing the line exists to say - as the last to go.
|
|
489
|
+
*
|
|
490
|
+
* Order is preference, NOT length: a stripped-down rung is occasionally a
|
|
491
|
+
* character or two longer than the rung above it. Sorting by length instead
|
|
492
|
+
* would let a form win or lose by two characters as a percentage crosses from
|
|
493
|
+
* `9%` to `10%`, and the line would change shape while the reader watches -
|
|
494
|
+
* the flicker this whole mode exists to remove.
|
|
495
|
+
*
|
|
496
|
+
* Candidates never exceed what {@link QuotaOverviewOptions} asked for, so a
|
|
497
|
+
* switch left off cannot reappear because the terminal happened to be wide.
|
|
498
|
+
*/
|
|
499
|
+
export function formatQuotaOverviewCandidates(accounts, options) {
|
|
500
|
+
const now = options.now ?? Date.now();
|
|
501
|
+
const maskEmail = options.maskEmail ?? false;
|
|
502
|
+
const total = computeWeightedLeftPercent(accounts);
|
|
503
|
+
if (total === undefined)
|
|
504
|
+
return [];
|
|
505
|
+
const totalText = formatQuotaPercent(total, options.mode);
|
|
506
|
+
const ordered = orderOverviewAccounts(accounts, options.order);
|
|
507
|
+
const usable = ordered.filter((account) => resolveGoverningWindow(account));
|
|
508
|
+
const recovery = options.recovery
|
|
509
|
+
? resolveQuotaOverviewRecovery(accounts, now)
|
|
510
|
+
: undefined;
|
|
511
|
+
const recoveryForms = recovery
|
|
512
|
+
? [true, false]
|
|
513
|
+
.map((words) => formatRecovery(recovery, { mode: options.mode, now, words }))
|
|
514
|
+
.filter((form) => Boolean(form))
|
|
515
|
+
: [];
|
|
516
|
+
const bodies = [];
|
|
517
|
+
if (options.layout !== "count") {
|
|
518
|
+
// Position identifies an account only when the accounts are in number
|
|
519
|
+
// order, none of them is missing from the line, and the numbers run
|
|
520
|
+
// 1..n with no gap - a deduplicated or disabled account leaves indices
|
|
521
|
+
// like #1, #3, where the second percentage is NOT account #2's.
|
|
522
|
+
const positionsAreComplete = options.order === "number" &&
|
|
523
|
+
usable.length === accounts.length &&
|
|
524
|
+
usable.every((account, position) => account.index === position + 1);
|
|
525
|
+
for (const rung of annotationRungs(options, positionsAreComplete)) {
|
|
526
|
+
const segmentOptions = {
|
|
527
|
+
...rung,
|
|
528
|
+
mode: options.mode,
|
|
529
|
+
maskEmail,
|
|
530
|
+
now,
|
|
531
|
+
};
|
|
532
|
+
const segments = options.layout === "aggregate"
|
|
533
|
+
? formatAggregateSegments(usable, segmentOptions)
|
|
534
|
+
: usable
|
|
535
|
+
.map((account) => formatAccountSegment(account, segmentOptions))
|
|
536
|
+
.filter((segment) => Boolean(segment));
|
|
537
|
+
if (segments.length === 0)
|
|
538
|
+
continue;
|
|
539
|
+
const text = segments.join(", ");
|
|
540
|
+
if (!bodies.includes(text))
|
|
541
|
+
bodies.push(text);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
// `66% of 65x` before `66%`: the allotment is a small, near-static
|
|
545
|
+
// annotation, so it is given up early - but not before any account detail,
|
|
546
|
+
// which is what the line is read for.
|
|
547
|
+
const heads = [];
|
|
548
|
+
if (options.allotment) {
|
|
549
|
+
const allotment = computePoolAllotment(accounts);
|
|
550
|
+
if (allotment !== undefined)
|
|
551
|
+
heads.push(`${totalText} of ${allotment}x`);
|
|
552
|
+
}
|
|
553
|
+
if (!heads.includes(totalText))
|
|
554
|
+
heads.push(totalText);
|
|
555
|
+
const candidates = [];
|
|
556
|
+
const push = (head, ...tail) => {
|
|
557
|
+
const body = tail.filter((part) => Boolean(part));
|
|
558
|
+
const text = body.length > 0 ? `${head}: ${body.join(", ")}` : head;
|
|
559
|
+
if (!candidates.includes(text))
|
|
560
|
+
candidates.push(text);
|
|
561
|
+
};
|
|
562
|
+
for (const body of bodies) {
|
|
563
|
+
for (const head of heads) {
|
|
564
|
+
for (const form of recoveryForms)
|
|
565
|
+
push(head, body, form);
|
|
566
|
+
push(head, body);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
for (const head of heads) {
|
|
570
|
+
// The count word is grammar, the recovery clause is information, so the
|
|
571
|
+
// word goes first. The count is the pool's size, not the number of
|
|
572
|
+
// accounts that could be read - `40%: 1 account` on a three-account
|
|
573
|
+
// pool would say a pool exists that does not.
|
|
574
|
+
if (recoveryForms.length > 0) {
|
|
575
|
+
for (const form of recoveryForms)
|
|
576
|
+
push(head, formatAccountCount(accounts.length, "long"), form);
|
|
577
|
+
const shortest = recoveryForms[recoveryForms.length - 1];
|
|
578
|
+
push(head, formatAccountCount(accounts.length, "short"), shortest);
|
|
579
|
+
push(head, formatAccountCount(accounts.length, "bare"), shortest);
|
|
580
|
+
}
|
|
581
|
+
push(head, formatAccountCount(accounts.length, "long"));
|
|
582
|
+
push(head, formatAccountCount(accounts.length, "short"));
|
|
583
|
+
push(head, formatAccountCount(accounts.length, "bare"));
|
|
584
|
+
}
|
|
585
|
+
for (const head of heads)
|
|
586
|
+
push(head);
|
|
587
|
+
return candidates;
|
|
588
|
+
}
|
|
589
|
+
/** The fullest rendering, for surfaces with a line to themselves. */
|
|
590
|
+
export function formatQuotaOverviewText(accounts, options) {
|
|
591
|
+
return formatQuotaOverviewCandidates(accounts, options)[0] ?? "";
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Every rendering of the banked reset credits, longest first.
|
|
595
|
+
*
|
|
596
|
+
* ```text
|
|
597
|
+
* Free resets: 6d 1r damian@nowaker.net, 4d 2r work@example.com
|
|
598
|
+
* ```
|
|
599
|
+
*
|
|
600
|
+
* Sorted by the LATEST reset first, which is redemption order rather than
|
|
601
|
+
* reading order: redeeming a credit on an account that renews by itself
|
|
602
|
+
* tomorrow throws the credit away, while the account six days out is the one
|
|
603
|
+
* worth spending it on.
|
|
604
|
+
*
|
|
605
|
+
* Returns nothing at all unless the pool is spent and something is redeemable.
|
|
606
|
+
* A reset credit is only an answer to "everything is used up"; offered while
|
|
607
|
+
* accounts still have headroom it is an invitation to waste it.
|
|
608
|
+
*/
|
|
609
|
+
export function formatQuotaResetsCandidates(accounts, options) {
|
|
610
|
+
if (!isPoolFullySpent(accounts))
|
|
611
|
+
return [];
|
|
612
|
+
const now = options.now ?? Date.now();
|
|
613
|
+
const maskEmail = options.maskEmail ?? false;
|
|
614
|
+
const redeemable = orderOverviewAccounts(accounts, "renewing-latest").filter((account) => resolveResetCredits(account) > 0);
|
|
615
|
+
if (redeemable.length === 0)
|
|
616
|
+
return [];
|
|
617
|
+
const durations = redeemable.map((account) => {
|
|
618
|
+
const resetAtMs = governingResetAtMs(account);
|
|
619
|
+
return resetAtMs === undefined
|
|
620
|
+
? undefined
|
|
621
|
+
: formatCompactDuration(resetAtMs - now);
|
|
622
|
+
});
|
|
623
|
+
const credits = redeemable.map((account) => resolveResetCredits(account));
|
|
624
|
+
const everyAccountHasOneCredit = credits.every((count) => count === 1);
|
|
625
|
+
// The identity follows `accountNames` like the pool line does: the full
|
|
626
|
+
// address is the longest form of the `label` name and appears only there,
|
|
627
|
+
// `number` gives `#n`, and `none` names no account at all.
|
|
628
|
+
const names = options.names ?? "label";
|
|
629
|
+
const unnamed = redeemable.map(() => undefined);
|
|
630
|
+
const identities = names === "none"
|
|
631
|
+
? [unnamed]
|
|
632
|
+
: names === "number"
|
|
633
|
+
? [
|
|
634
|
+
redeemable.map((account) => resolveAccountName(account, "number", maskEmail)),
|
|
635
|
+
]
|
|
636
|
+
: [
|
|
637
|
+
redeemable.map((account) => resolveAccountEmail(account, maskEmail)),
|
|
638
|
+
redeemable.map((account) => resolveAccountName(account, "label", maskEmail)),
|
|
639
|
+
redeemable.map((account) => resolveAccountName(account, "number", maskEmail)),
|
|
640
|
+
];
|
|
641
|
+
const candidates = [];
|
|
642
|
+
const add = (text) => {
|
|
643
|
+
if (!candidates.includes(text))
|
|
644
|
+
candidates.push(text);
|
|
645
|
+
};
|
|
646
|
+
const push = (prefix, identity, withDuration, withCredits) => {
|
|
647
|
+
const segments = redeemable.map((_account, position) => {
|
|
648
|
+
const parts = [];
|
|
649
|
+
const duration = durations[position];
|
|
650
|
+
if (withDuration && duration)
|
|
651
|
+
parts.push(duration);
|
|
652
|
+
if (withCredits)
|
|
653
|
+
parts.push(`${credits[position]}r`);
|
|
654
|
+
const name = identity[position];
|
|
655
|
+
if (name)
|
|
656
|
+
parts.push(name);
|
|
657
|
+
return parts.join(" ");
|
|
658
|
+
});
|
|
659
|
+
// A form that would render one account as an empty segment is not a
|
|
660
|
+
// shorter rendering of this line, it is a different and wrong one.
|
|
661
|
+
if (segments.some((segment) => segment.length === 0))
|
|
662
|
+
return;
|
|
663
|
+
add(`${prefix} ${segments.join(", ")}`);
|
|
664
|
+
};
|
|
665
|
+
// The word `Free` is given up before any account detail is, and the
|
|
666
|
+
// identity then shortens from the fullest configured form - the full
|
|
667
|
+
// address under `label` - down to the number `codex-reset` takes.
|
|
668
|
+
push("Free resets:", identities[0] ?? unnamed, true, true);
|
|
669
|
+
for (const identity of identities)
|
|
670
|
+
push("Resets:", identity, true, true);
|
|
671
|
+
// Only once every identity form has been tried does the line start giving
|
|
672
|
+
// up facts: the countdown is the reason one account is a better redemption
|
|
673
|
+
// than another, and the credit count stops being news when every account
|
|
674
|
+
// holds exactly one.
|
|
675
|
+
for (const identity of identities) {
|
|
676
|
+
if (everyAccountHasOneCredit)
|
|
677
|
+
push("Resets:", identity, true, false);
|
|
678
|
+
push("Resets:", identity, false, true);
|
|
679
|
+
push("Resets:", identity, false, false);
|
|
680
|
+
}
|
|
681
|
+
add(`Resets: ${redeemable.length}`);
|
|
682
|
+
return candidates;
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Headroom of the account with the most room left, for the caller that
|
|
686
|
+
* colours the line.
|
|
687
|
+
*
|
|
688
|
+
* Deliberately the best account rather than the worst: a pool is only in
|
|
689
|
+
* trouble when nothing in it has room left, and keying on the worst account
|
|
690
|
+
* would paint the line red for one spent seat that rotation has already
|
|
691
|
+
* stopped selecting while every other account serves requests normally.
|
|
692
|
+
*/
|
|
693
|
+
export function resolveQuotaOverviewTonePercent(accounts) {
|
|
694
|
+
let best;
|
|
695
|
+
for (const account of accounts) {
|
|
696
|
+
const governing = resolveGoverningWindow(account);
|
|
697
|
+
if (!governing || !isPercent(governing.leftPercent))
|
|
698
|
+
continue;
|
|
699
|
+
if (best === undefined || governing.leftPercent > best) {
|
|
700
|
+
best = governing.leftPercent;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
return best;
|
|
704
|
+
}
|
|
705
|
+
/** Re-exported so callers rendering a bare total need only this module. */
|
|
706
|
+
export { toQuotaDisplayPercent };
|
|
707
|
+
//# sourceMappingURL=quota-overview.js.map
|