oc-codex-multi-auth 6.21.0 → 6.23.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.
Files changed (182) hide show
  1. package/README.md +168 -10
  2. package/config/README.md +53 -33
  3. package/config/opencode-legacy.json +129 -279
  4. package/config/opencode-modern.json +43 -132
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +293 -57
  7. package/dist/index.js.map +1 -1
  8. package/dist/lib/account-display.d.ts +46 -0
  9. package/dist/lib/account-display.d.ts.map +1 -1
  10. package/dist/lib/account-display.js +260 -0
  11. package/dist/lib/account-display.js.map +1 -1
  12. package/dist/lib/accounts/warm-request.d.ts.map +1 -1
  13. package/dist/lib/accounts/warm-request.js +9 -11
  14. package/dist/lib/accounts/warm-request.js.map +1 -1
  15. package/dist/lib/accounts.d.ts +4 -0
  16. package/dist/lib/accounts.d.ts.map +1 -1
  17. package/dist/lib/accounts.js +20 -15
  18. package/dist/lib/accounts.js.map +1 -1
  19. package/dist/lib/auth/login-runner.d.ts.map +1 -1
  20. package/dist/lib/auth/login-runner.js +79 -2
  21. package/dist/lib/auth/login-runner.js.map +1 -1
  22. package/dist/lib/auto-update-checker.d.ts +17 -1
  23. package/dist/lib/auto-update-checker.d.ts.map +1 -1
  24. package/dist/lib/auto-update-checker.js +49 -3
  25. package/dist/lib/auto-update-checker.js.map +1 -1
  26. package/dist/lib/cli.d.ts +1 -0
  27. package/dist/lib/cli.d.ts.map +1 -1
  28. package/dist/lib/cli.js +9 -3
  29. package/dist/lib/cli.js.map +1 -1
  30. package/dist/lib/codex-usage.d.ts +4 -3
  31. package/dist/lib/codex-usage.d.ts.map +1 -1
  32. package/dist/lib/codex-usage.js +13 -11
  33. package/dist/lib/codex-usage.js.map +1 -1
  34. package/dist/lib/config.d.ts +75 -0
  35. package/dist/lib/config.d.ts.map +1 -1
  36. package/dist/lib/config.js +152 -1
  37. package/dist/lib/config.js.map +1 -1
  38. package/dist/lib/constants.d.ts +13 -0
  39. package/dist/lib/constants.d.ts.map +1 -1
  40. package/dist/lib/constants.js +13 -0
  41. package/dist/lib/constants.js.map +1 -1
  42. package/dist/lib/plan-allotment.d.ts +72 -0
  43. package/dist/lib/plan-allotment.d.ts.map +1 -0
  44. package/dist/lib/plan-allotment.js +142 -0
  45. package/dist/lib/plan-allotment.js.map +1 -0
  46. package/dist/lib/plugin-origin.d.ts +77 -0
  47. package/dist/lib/plugin-origin.d.ts.map +1 -0
  48. package/dist/lib/plugin-origin.js +301 -0
  49. package/dist/lib/plugin-origin.js.map +1 -0
  50. package/dist/lib/prompts/codex.d.ts +12 -2
  51. package/dist/lib/prompts/codex.d.ts.map +1 -1
  52. package/dist/lib/prompts/codex.js +47 -16
  53. package/dist/lib/prompts/codex.js.map +1 -1
  54. package/dist/lib/quota-display.d.ts +40 -0
  55. package/dist/lib/quota-display.d.ts.map +1 -0
  56. package/dist/lib/quota-display.js +45 -0
  57. package/dist/lib/quota-display.js.map +1 -0
  58. package/dist/lib/quota-notifications.d.ts +2 -1
  59. package/dist/lib/quota-notifications.d.ts.map +1 -1
  60. package/dist/lib/quota-notifications.js +34 -14
  61. package/dist/lib/quota-notifications.js.map +1 -1
  62. package/dist/lib/quota-overview.d.ts +253 -0
  63. package/dist/lib/quota-overview.d.ts.map +1 -0
  64. package/dist/lib/quota-overview.js +707 -0
  65. package/dist/lib/quota-overview.js.map +1 -0
  66. package/dist/lib/request/fetch-helpers.d.ts.map +1 -1
  67. package/dist/lib/request/fetch-helpers.js +84 -50
  68. package/dist/lib/request/fetch-helpers.js.map +1 -1
  69. package/dist/lib/request/helpers/model-map.d.ts +10 -0
  70. package/dist/lib/request/helpers/model-map.d.ts.map +1 -1
  71. package/dist/lib/request/helpers/model-map.js +39 -11
  72. package/dist/lib/request/helpers/model-map.js.map +1 -1
  73. package/dist/lib/request/helpers/responses-lite.d.ts.map +1 -1
  74. package/dist/lib/request/helpers/responses-lite.js +4 -1
  75. package/dist/lib/request/helpers/responses-lite.js.map +1 -1
  76. package/dist/lib/request/request-transformer.d.ts.map +1 -1
  77. package/dist/lib/request/request-transformer.js +37 -15
  78. package/dist/lib/request/request-transformer.js.map +1 -1
  79. package/dist/lib/request/retry-budget.d.ts +26 -0
  80. package/dist/lib/request/retry-budget.d.ts.map +1 -1
  81. package/dist/lib/request/retry-budget.js +43 -0
  82. package/dist/lib/request/retry-budget.js.map +1 -1
  83. package/dist/lib/schemas.d.ts +73 -0
  84. package/dist/lib/schemas.d.ts.map +1 -1
  85. package/dist/lib/schemas.js +53 -0
  86. package/dist/lib/schemas.js.map +1 -1
  87. package/dist/lib/storage/backup.d.ts +33 -0
  88. package/dist/lib/storage/backup.d.ts.map +1 -1
  89. package/dist/lib/storage/backup.js +43 -7
  90. package/dist/lib/storage/backup.js.map +1 -1
  91. package/dist/lib/storage/credential-snapshots.d.ts +78 -0
  92. package/dist/lib/storage/credential-snapshots.d.ts.map +1 -0
  93. package/dist/lib/storage/credential-snapshots.js +302 -0
  94. package/dist/lib/storage/credential-snapshots.js.map +1 -0
  95. package/dist/lib/storage/export-import.d.ts.map +1 -1
  96. package/dist/lib/storage/export-import.js +7 -0
  97. package/dist/lib/storage/export-import.js.map +1 -1
  98. package/dist/lib/storage/flagged.d.ts.map +1 -1
  99. package/dist/lib/storage/flagged.js +23 -1
  100. package/dist/lib/storage/flagged.js.map +1 -1
  101. package/dist/lib/storage/load-save.d.ts +4 -0
  102. package/dist/lib/storage/load-save.d.ts.map +1 -1
  103. package/dist/lib/storage/load-save.js +50 -0
  104. package/dist/lib/storage/load-save.js.map +1 -1
  105. package/dist/lib/storage/paths.d.ts +1 -0
  106. package/dist/lib/storage/paths.d.ts.map +1 -1
  107. package/dist/lib/storage/paths.js +1 -1
  108. package/dist/lib/storage/paths.js.map +1 -1
  109. package/dist/lib/storage/test-home-guard.d.ts +35 -0
  110. package/dist/lib/storage/test-home-guard.d.ts.map +1 -0
  111. package/dist/lib/storage/test-home-guard.js +59 -0
  112. package/dist/lib/storage/test-home-guard.js.map +1 -0
  113. package/dist/lib/tools/codex-dashboard.d.ts.map +1 -1
  114. package/dist/lib/tools/codex-dashboard.js +3 -2
  115. package/dist/lib/tools/codex-dashboard.js.map +1 -1
  116. package/dist/lib/tools/codex-doctor.d.ts.map +1 -1
  117. package/dist/lib/tools/codex-doctor.js +16 -0
  118. package/dist/lib/tools/codex-doctor.js.map +1 -1
  119. package/dist/lib/tools/codex-health.d.ts.map +1 -1
  120. package/dist/lib/tools/codex-health.js +10 -2
  121. package/dist/lib/tools/codex-health.js.map +1 -1
  122. package/dist/lib/tools/codex-label.d.ts.map +1 -1
  123. package/dist/lib/tools/codex-label.js +1 -0
  124. package/dist/lib/tools/codex-label.js.map +1 -1
  125. package/dist/lib/tools/codex-limits.d.ts.map +1 -1
  126. package/dist/lib/tools/codex-limits.js +13 -9
  127. package/dist/lib/tools/codex-limits.js.map +1 -1
  128. package/dist/lib/tools/codex-list.d.ts.map +1 -1
  129. package/dist/lib/tools/codex-list.js +28 -3
  130. package/dist/lib/tools/codex-list.js.map +1 -1
  131. package/dist/lib/tools/codex-note.d.ts.map +1 -1
  132. package/dist/lib/tools/codex-note.js +4 -1
  133. package/dist/lib/tools/codex-note.js.map +1 -1
  134. package/dist/lib/tools/codex-pool.d.ts.map +1 -1
  135. package/dist/lib/tools/codex-pool.js +6 -2
  136. package/dist/lib/tools/codex-pool.js.map +1 -1
  137. package/dist/lib/tools/codex-refresh.d.ts.map +1 -1
  138. package/dist/lib/tools/codex-refresh.js +4 -1
  139. package/dist/lib/tools/codex-refresh.js.map +1 -1
  140. package/dist/lib/tools/codex-remove.d.ts.map +1 -1
  141. package/dist/lib/tools/codex-remove.js +1 -0
  142. package/dist/lib/tools/codex-remove.js.map +1 -1
  143. package/dist/lib/tools/codex-reset.d.ts.map +1 -1
  144. package/dist/lib/tools/codex-reset.js +13 -7
  145. package/dist/lib/tools/codex-reset.js.map +1 -1
  146. package/dist/lib/tools/codex-status.d.ts.map +1 -1
  147. package/dist/lib/tools/codex-status.js +25 -2
  148. package/dist/lib/tools/codex-status.js.map +1 -1
  149. package/dist/lib/tools/codex-switch.d.ts.map +1 -1
  150. package/dist/lib/tools/codex-switch.js +1 -0
  151. package/dist/lib/tools/codex-switch.js.map +1 -1
  152. package/dist/lib/tools/codex-tag.d.ts.map +1 -1
  153. package/dist/lib/tools/codex-tag.js +4 -1
  154. package/dist/lib/tools/codex-tag.js.map +1 -1
  155. package/dist/lib/tools/codex-warm.d.ts.map +1 -1
  156. package/dist/lib/tools/codex-warm.js +1 -0
  157. package/dist/lib/tools/codex-warm.js.map +1 -1
  158. package/dist/lib/tools/index.d.ts +9 -0
  159. package/dist/lib/tools/index.d.ts.map +1 -1
  160. package/dist/lib/tools/index.js.map +1 -1
  161. package/dist/lib/tui-quota-cache.d.ts +35 -0
  162. package/dist/lib/tui-quota-cache.d.ts.map +1 -1
  163. package/dist/lib/tui-quota-cache.js +76 -11
  164. package/dist/lib/tui-quota-cache.js.map +1 -1
  165. package/dist/lib/tui-quota-overview.d.ts +55 -0
  166. package/dist/lib/tui-quota-overview.d.ts.map +1 -0
  167. package/dist/lib/tui-quota-overview.js +209 -0
  168. package/dist/lib/tui-quota-overview.js.map +1 -0
  169. package/dist/lib/tui-status.d.ts +55 -0
  170. package/dist/lib/tui-status.d.ts.map +1 -1
  171. package/dist/lib/tui-status.js +155 -14
  172. package/dist/lib/tui-status.js.map +1 -1
  173. package/dist/lib/ui/auth-menu.d.ts +2 -0
  174. package/dist/lib/ui/auth-menu.d.ts.map +1 -1
  175. package/dist/lib/ui/auth-menu.js +10 -6
  176. package/dist/lib/ui/auth-menu.js.map +1 -1
  177. package/dist/tui.d.ts +55 -0
  178. package/dist/tui.d.ts.map +1 -1
  179. package/dist/tui.js +511 -29
  180. package/dist/tui.js.map +1 -1
  181. package/package.json +1 -1
  182. package/scripts/install-oc-codex-multi-auth-core.js +583 -66
package/dist/tui.js CHANGED
@@ -1,13 +1,67 @@
1
- import { getCodexTuiMaskEmail, getCodexTuiMaskEmailInQuotaDetails, loadPluginConfig, } from "./lib/config.js";
1
+ import { getCodexTuiMaskEmail, getCodexTuiMaskEmailInQuotaDetails, getQuotaDisplay, getQuotaStatus, loadPluginConfig, } from "./lib/config.js";
2
+ import { PROVIDER_ID } from "./lib/constants.js";
3
+ import { fetchTuiQuotaOverview, mergeOverviewWithLatestAccount, toQuotaOverviewAccounts, } from "./lib/tui-quota-overview.js";
2
4
  import { createUsageAccountFingerprint, ensureCodexUsageAccessToken, fetchCodexUsage, formatUsageWindowLabel, getUsageLeftPercent, hasUsageWindow, parseCodexUsagePayload, resolveCodexUsageAccountId, resolveCodexUsageActiveAccount, } from "./lib/codex-usage.js";
3
- import { formatPromptStatusText, formatQuotaDetailsText, resolveQuotaPromptTone, } from "./lib/tui-status.js";
4
- import { createTuiQuotaSnapshot, getTuiQuotaCachePath, isFreshTuiQuotaSnapshot, isTuiQuotaSnapshot, readTuiQuotaSnapshot, writeTuiQuotaSnapshot, } from "./lib/tui-quota-cache.js";
5
+ import { formatPromptStatusText, formatQuotaDetailsText, formatQuotaOverviewStatusLines, formatQuotaResetsStatusLines, resolveQuotaOverviewTone, resolveQuotaPromptTone, } from "./lib/tui-status.js";
6
+ import { createTuiQuotaSnapshot, getTuiQuotaCachePath, getTuiQuotaOverviewCachePath, isFreshTuiQuotaSnapshot, isTuiQuotaSnapshot, readTuiQuotaSnapshot, writeTuiQuotaSnapshot, } from "./lib/tui-quota-cache.js";
5
7
  import { loadAccounts } from "./lib/storage.js";
6
8
  const CACHE_KEY = "oc-codex-multi-auth:tui-status:v2";
7
9
  const REFRESH_INTERVAL_MS = 5 * 60 * 1000;
8
10
  const EVENT_REFRESH_DEBOUNCE_MS = 750;
9
11
  const ACCOUNT_POLL_INTERVAL_MS = 1_000;
12
+ // The status line is the one surface that reads this configuration once and
13
+ // then renders it for the rest of the session, so it is the only one where an
14
+ // edit would otherwise need a restart to be seen. `loadPluginConfig` already
15
+ // re-reads the file on every call and only skips re-parsing when the bytes are
16
+ // unchanged, so polling it costs one read of a file measured in hundreds of
17
+ // bytes.
18
+ const CONFIG_POLL_INTERVAL_MS = 2_000;
19
+ // Re-reading three numbers off the laid-out node, which is cheap enough to do
20
+ // often and has to be: the first pass happens before any layout exists, and
21
+ // until it is picked up the line is budgeting against the whole terminal.
22
+ const LAYOUT_MEASURE_INTERVAL_MS = 500;
10
23
  let inFlightRefresh;
24
+ let inFlightOverview;
25
+ async function refreshQuotaOverviewInner(api) {
26
+ try {
27
+ const now = Date.now();
28
+ const snapshot = await fetchTuiQuotaOverview({
29
+ cachePath: getTuiQuotaOverviewCachePath(api.state.path.state),
30
+ now,
31
+ });
32
+ if (!snapshot || snapshot.accounts.length === 0) {
33
+ return { type: "unavailable" };
34
+ }
35
+ const merged = mergeOverviewWithLatestAccount(snapshot, await readSharedQuotaStatus(api));
36
+ return {
37
+ type: "ready",
38
+ accounts: toQuotaOverviewAccounts(merged),
39
+ // Judged on the poll, not on the merged account: one account read a
40
+ // moment ago does not make a five-minute-old reading of the other six
41
+ // current.
42
+ stale: !isFreshTuiQuotaSnapshot(merged, now),
43
+ };
44
+ }
45
+ catch {
46
+ return { type: "unavailable" };
47
+ }
48
+ }
49
+ /**
50
+ * Coalesce concurrent refreshes.
51
+ *
52
+ * Every session event that can move a quota schedules one of these, and
53
+ * without this guard a burst of tool completions would start several passes
54
+ * over the whole pool at once. The pass itself is already cheap while the
55
+ * cache is fresh - it reads one file - so the events can stay wired to it.
56
+ */
57
+ function refreshQuotaOverview(api) {
58
+ if (inFlightOverview)
59
+ return inFlightOverview;
60
+ inFlightOverview = refreshQuotaOverviewInner(api).finally(() => {
61
+ inFlightOverview = undefined;
62
+ });
63
+ return inFlightOverview;
64
+ }
11
65
  function isStoredQuotaStatus(value) {
12
66
  return isTuiQuotaSnapshot(value);
13
67
  }
@@ -215,7 +269,242 @@ export function shouldRefreshQuotaForEvent(event) {
215
269
  return false;
216
270
  }
217
271
  }
218
- function createPromptStatus(api, solid, options) {
272
+ export function readPromptStatusOptions() {
273
+ const pluginConfig = loadPluginConfig();
274
+ return {
275
+ maskEmail: getCodexTuiMaskEmail(pluginConfig),
276
+ maskEmailInQuotaDetails: getCodexTuiMaskEmailInQuotaDetails(pluginConfig),
277
+ quotaDisplay: getQuotaDisplay(pluginConfig),
278
+ quotaStatus: getQuotaStatus(pluginConfig),
279
+ };
280
+ }
281
+ /**
282
+ * Whether two readings of the configuration would render the same line.
283
+ *
284
+ * Compared field by field rather than by identity: every poll builds a fresh
285
+ * object, so identity always differs and pushing it into a signal would
286
+ * re-render the status line every two seconds forever.
287
+ */
288
+ export function samePromptStatusOptions(left, right) {
289
+ const leftStatus = left.quotaStatus;
290
+ const rightStatus = right.quotaStatus;
291
+ return (left.maskEmail === right.maskEmail &&
292
+ left.maskEmailInQuotaDetails === right.maskEmailInQuotaDetails &&
293
+ left.quotaDisplay === right.quotaDisplay &&
294
+ leftStatus.screens.length === rightStatus.screens.length &&
295
+ leftStatus.screens.every((screen, position) => screen === rightStatus.screens[position]) &&
296
+ leftStatus.rotateMs === rightStatus.rotateMs &&
297
+ leftStatus.layout === rightStatus.layout &&
298
+ leftStatus.accountNames === rightStatus.accountNames &&
299
+ leftStatus.order === rightStatus.order &&
300
+ leftStatus.multipliers === rightStatus.multipliers &&
301
+ leftStatus.allotment === rightStatus.allotment &&
302
+ leftStatus.resetTimes === rightStatus.resetTimes &&
303
+ leftStatus.resetCredits === rightStatus.resetCredits &&
304
+ leftStatus.recovery === rightStatus.recovery &&
305
+ leftStatus.rows === rightStatus.rows &&
306
+ leftStatus.showFor === rightStatus.showFor);
307
+ }
308
+ /**
309
+ * Columns on the prompt's bottom row that belong to the model label beside
310
+ * this line, plus the gap between them.
311
+ *
312
+ * A constant rather than the label's measured width, and that is the whole
313
+ * point. The row lays both boxes out by their content, so a label with no room
314
+ * left is SHRUNK to whatever this line did not take - measuring it would make
315
+ * the budget a function of the line's own length, and every render would
316
+ * ratchet it further down. The row's width is the one number on that row that
317
+ * this line cannot influence.
318
+ *
319
+ * 40 is measured against the real TUI: "Build - Big Pickle OpenCode Zen" is 31
320
+ * characters, and at 80 columns a 48-character line was ellipsized through its
321
+ * middle AND pushed the label onto a second row.
322
+ */
323
+ const PROMPT_LABEL_RESERVED_CHARS = 40;
324
+ const MAX_LAYOUT_WALK_DEPTH = 8;
325
+ function asLayoutNode(value) {
326
+ return typeof value === "object" && value !== null ? value : undefined;
327
+ }
328
+ function layoutSize(value) {
329
+ return typeof value === "number" && Number.isFinite(value) && value > 0
330
+ ? value
331
+ : undefined;
332
+ }
333
+ function layoutChildren(node) {
334
+ if (typeof node.getChildren !== "function")
335
+ return [];
336
+ try {
337
+ const children = node.getChildren();
338
+ if (!Array.isArray(children))
339
+ return [];
340
+ return children
341
+ .map((child) => asLayoutNode(child))
342
+ .filter((child) => Boolean(child));
343
+ }
344
+ catch {
345
+ return [];
346
+ }
347
+ }
348
+ /**
349
+ * Measure the row this line sits on, by walking up to it.
350
+ *
351
+ * The walk looks for the nearest ancestor laid out as a row with something else
352
+ * in it - the prompt's bottom row, where the model label is the something else.
353
+ * That row stretches to the prompt's inner width, which is the number the
354
+ * terminal width fails to be whenever a sidebar is open, and the only number on
355
+ * that row this line does not influence.
356
+ *
357
+ * Every hop is guarded and the whole thing returns nothing rather than a guess:
358
+ * before the first layout pass there are no widths to read, and a host that
359
+ * rearranges its prompt must degrade to the width heuristic rather than compute
360
+ * a budget from numbers that no longer mean what they did.
361
+ */
362
+ export function measureStatusSlot(node) {
363
+ const chain = [];
364
+ let current = asLayoutNode(node);
365
+ for (let depth = 0; current && depth < MAX_LAYOUT_WALK_DEPTH; depth += 1) {
366
+ chain.push(current);
367
+ current = asLayoutNode(current.parent);
368
+ }
369
+ for (const [position, ancestor] of chain.entries()) {
370
+ if (position === 0)
371
+ continue;
372
+ if (ancestor.primaryAxis !== "row")
373
+ continue;
374
+ const rowWidth = layoutSize(ancestor.width);
375
+ if (!rowWidth)
376
+ continue;
377
+ const children = layoutChildren(ancestor);
378
+ if (children.length < 2)
379
+ continue;
380
+ const mine = chain[position - 1];
381
+ if (!mine || !children.includes(mine))
382
+ continue;
383
+ const availableChars = rowWidth - PROMPT_LABEL_RESERVED_CHARS;
384
+ if (availableChars < 1)
385
+ continue;
386
+ return { availableChars };
387
+ }
388
+ return {};
389
+ }
390
+ function toQuotaOverviewOptions(options, now) {
391
+ // Spelled out rather than spread: `QuotaStatusConfig` calls the free/used
392
+ // wording `quotaDisplay` and its own screen list `screens`, while
393
+ // `QuotaOverviewOptions.mode` IS the wording - spreading one over the other
394
+ // silently renders every percentage as headroom.
395
+ return {
396
+ mode: options.quotaDisplay,
397
+ layout: options.quotaStatus.layout,
398
+ names: options.quotaStatus.accountNames,
399
+ order: options.quotaStatus.order,
400
+ multipliers: options.quotaStatus.multipliers,
401
+ allotment: options.quotaStatus.allotment,
402
+ resetTimes: options.quotaStatus.resetTimes,
403
+ resetCredits: options.quotaStatus.resetCredits,
404
+ recovery: options.quotaStatus.recovery,
405
+ maskEmail: options.maskEmail,
406
+ now,
407
+ };
408
+ }
409
+ /**
410
+ * The pool-wide status line's data pipeline.
411
+ *
412
+ * Kept apart from the active-account one rather than branching inside it:
413
+ * that one maintains a serving-account fingerprint, a snapshot revision and a
414
+ * one-second identity poll, all of which exist to answer "which account is
415
+ * this" - the question this mode is built to stop asking.
416
+ */
417
+ function createOverviewQuotaController(api, solid) {
418
+ const [state, setState] = solid.createSignal({
419
+ type: "loading",
420
+ });
421
+ const refresh = () => {
422
+ void refreshQuotaOverview(api).then(setState, () => {
423
+ setState({ type: "unavailable" });
424
+ });
425
+ };
426
+ let refreshTimeout;
427
+ const scheduleRefresh = () => {
428
+ if (refreshTimeout)
429
+ clearTimeout(refreshTimeout);
430
+ refreshTimeout = setTimeout(() => {
431
+ refreshTimeout = undefined;
432
+ refresh();
433
+ }, EVENT_REFRESH_DEBOUNCE_MS);
434
+ };
435
+ refresh();
436
+ const interval = setInterval(refresh, REFRESH_INTERVAL_MS);
437
+ const disposers = [
438
+ api.event.on("message.updated", (event) => {
439
+ if (shouldRefreshQuotaForEvent(event))
440
+ scheduleRefresh();
441
+ }),
442
+ api.event.on("message.part.updated", (event) => {
443
+ if (shouldRefreshQuotaForEvent(event))
444
+ scheduleRefresh();
445
+ }),
446
+ api.event.on("session.idle", (event) => {
447
+ if (shouldRefreshQuotaForEvent(event))
448
+ scheduleRefresh();
449
+ }),
450
+ api.event.on("session.status", (event) => {
451
+ if (shouldRefreshQuotaForEvent(event))
452
+ scheduleRefresh();
453
+ }),
454
+ api.event.on("session.error", (event) => {
455
+ if (shouldRefreshQuotaForEvent(event))
456
+ scheduleRefresh();
457
+ }),
458
+ ];
459
+ return {
460
+ screens: ["overview", "resets"],
461
+ lines(screen, options, layout) {
462
+ const current = state();
463
+ if (current.type !== "ready") {
464
+ // Blank while the first pass runs; a placeholder swapped out a
465
+ // moment later is exactly the flicker this mode removes. The
466
+ // reset screen stays blank either way - it has nothing to add
467
+ // to a pool nobody has read yet.
468
+ if (screen === "resets")
469
+ return [];
470
+ return current.type === "loading" ? [] : ["limits ?"];
471
+ }
472
+ const render = screen === "resets"
473
+ ? formatQuotaResetsStatusLines
474
+ : formatQuotaOverviewStatusLines;
475
+ return render({
476
+ accounts: current.accounts,
477
+ options: toQuotaOverviewOptions(options, Date.now()),
478
+ width: api.renderer.width,
479
+ availableChars: layout.availableChars,
480
+ maxRows: layout.maxRows,
481
+ });
482
+ },
483
+ tone(screen) {
484
+ const current = state();
485
+ if (current.type === "ready") {
486
+ // The reset screen only appears once nothing has headroom left,
487
+ // so the pool tone it would otherwise carry is always danger and
488
+ // says nothing. Warning is the honest colour for "here is the
489
+ // thing you can still do about it".
490
+ if (screen === "resets") {
491
+ return current.stale ? "stale" : "warning";
492
+ }
493
+ return resolveQuotaOverviewTone(current.accounts, current.stale);
494
+ }
495
+ return current.type === "loading" ? "unknown" : "warning";
496
+ },
497
+ dispose() {
498
+ clearInterval(interval);
499
+ if (refreshTimeout)
500
+ clearTimeout(refreshTimeout);
501
+ for (const dispose of disposers)
502
+ dispose();
503
+ },
504
+ };
505
+ }
506
+ /** The serving-account pipeline: the status line's original behaviour. */
507
+ function createActiveQuotaController(api, solid) {
219
508
  const [quota, setQuota] = solid.createSignal({
220
509
  type: "loading",
221
510
  });
@@ -303,29 +592,217 @@ function createPromptStatus(api, solid, options) {
303
592
  if (shouldRefreshQuotaForEvent(event))
304
593
  scheduleRefresh();
305
594
  });
306
- solid.onCleanup(() => {
307
- clearInterval(interval);
308
- clearInterval(accountInterval);
309
- if (refreshTimeout)
310
- clearTimeout(refreshTimeout);
311
- disposeMessageUpdated();
312
- disposeMessagePartUpdated();
313
- disposeSessionIdle();
314
- disposeSessionStatus();
315
- disposeSessionError();
316
- });
317
- const node = solid.createElement("text");
318
- solid.spread(node, {
319
- get content() {
320
- return formatPromptStatusText({
595
+ return {
596
+ screens: ["active"],
597
+ lines(_screen, options) {
598
+ const text = formatPromptStatusText({
321
599
  quota: quota(),
322
600
  width: api.renderer.width,
323
601
  maskEmail: options.maskEmail,
602
+ quotaDisplay: options.quotaDisplay,
324
603
  });
604
+ return text ? [text] : [];
605
+ },
606
+ tone() {
607
+ return resolveQuotaPromptTone(quota());
608
+ },
609
+ dispose() {
610
+ clearInterval(interval);
611
+ clearInterval(accountInterval);
612
+ if (refreshTimeout)
613
+ clearTimeout(refreshTimeout);
614
+ disposeMessageUpdated();
615
+ disposeMessagePartUpdated();
616
+ disposeSessionIdle();
617
+ disposeSessionStatus();
618
+ disposeSessionError();
619
+ },
620
+ };
621
+ }
622
+ /**
623
+ * The status-line node, which outlives any change to how it is configured.
624
+ *
625
+ * Both the shape of the line and the pipeline behind it come from a file the
626
+ * user edits while sessions are open, so this polls that file and swaps the
627
+ * pipeline underneath a node the renderer keeps mounted. The alternative -
628
+ * re-registering the slot - would ask the renderer to replace a live node, and
629
+ * a mode change is exactly when it must not blink.
630
+ */
631
+ /**
632
+ * The pipelines a set of screens needs, and which screen each one answers.
633
+ *
634
+ * `resets` is served by the pool pipeline rather than one of its own: it needs
635
+ * every account's windows and banked credits, which is exactly what that
636
+ * pipeline already gathers, and a second poller reading the same endpoint for
637
+ * the same numbers would double the request cost to say the same thing.
638
+ */
639
+ function createQuotaControllers(api, solid, screens) {
640
+ const controllers = [];
641
+ if (screens.includes("active")) {
642
+ controllers.push(createActiveQuotaController(api, solid));
643
+ }
644
+ if (screens.includes("overview") || screens.includes("resets")) {
645
+ controllers.push(createOverviewQuotaController(api, solid));
646
+ }
647
+ return controllers;
648
+ }
649
+ /**
650
+ * Whether the account pool is worth naming for the model in front of the user.
651
+ *
652
+ * Unknown counts as yes. A line the user asked for should not disappear
653
+ * because a fresh session has no message to read a provider off yet, and being
654
+ * told about a pool one model does not draw from is a smaller wrong than being
655
+ * told nothing while it runs out.
656
+ */
657
+ export function showsQuotaForSession(params) {
658
+ if (params.audience === "always")
659
+ return true;
660
+ return !params.providerID || params.providerID === PROVIDER_ID;
661
+ }
662
+ function getString(value) {
663
+ return typeof value === "string" && value.trim().length > 0
664
+ ? value.trim()
665
+ : undefined;
666
+ }
667
+ /**
668
+ * The provider actually serving this session, newest statement first.
669
+ *
670
+ * A message records what ran, which is the only reliable answer once a session
671
+ * has moved off the configured default; the configured model is the fallback
672
+ * for a session that has not run anything yet.
673
+ */
674
+ function resolveSessionProviderId(api, sessionID) {
675
+ if (sessionID) {
676
+ try {
677
+ const messages = api.state.session.messages(sessionID);
678
+ for (let position = messages.length - 1; position >= 0; position -= 1) {
679
+ const message = messages[position];
680
+ if (message?.role !== "assistant")
681
+ continue;
682
+ const providerID = getString(message.providerID);
683
+ if (providerID)
684
+ return providerID;
685
+ }
686
+ }
687
+ catch {
688
+ // The session may not be in the store yet; fall through to config.
689
+ }
690
+ }
691
+ const configured = getString(api.state.config.model);
692
+ if (!configured)
693
+ return undefined;
694
+ const slashIndex = configured.indexOf("/");
695
+ return slashIndex > 0 ? configured.slice(0, slashIndex) : undefined;
696
+ }
697
+ /**
698
+ * The status-line node, which outlives any change to how it is configured.
699
+ *
700
+ * Both the shape of the line and the pipelines behind it come from a file the
701
+ * user edits while sessions are open, so this polls that file and swaps the
702
+ * pipelines underneath a node the renderer keeps mounted. The alternative -
703
+ * re-registering the slot - would ask the renderer to replace a live node, and
704
+ * a screen change is exactly when it must not blink.
705
+ */
706
+ function createPromptStatus(api, solid, initialOptions, sessionID) {
707
+ const [options, setOptions] = solid.createSignal(initialOptions);
708
+ const [metrics, setMetrics] = solid.createSignal({});
709
+ const [rotation, setRotation] = solid.createSignal(0);
710
+ let controllers = createQuotaControllers(api, solid, initialOptions.quotaStatus.screens);
711
+ let controllerScreens = initialOptions.quotaStatus.screens;
712
+ let rotationInterval;
713
+ const node = solid.createElement("text");
714
+ const restartRotation = (screens) => {
715
+ if (rotationInterval)
716
+ clearInterval(rotationInterval);
717
+ rotationInterval = undefined;
718
+ if (screens.length < 2)
719
+ return;
720
+ rotationInterval = setInterval(() => {
721
+ setRotation((tick) => tick + 1);
722
+ }, options().quotaStatus.rotateMs);
723
+ };
724
+ restartRotation(controllerScreens);
725
+ const remeasure = () => {
726
+ const next = measureStatusSlot(node);
727
+ if (next.availableChars === metrics().availableChars)
728
+ return;
729
+ setMetrics(next);
730
+ };
731
+ const measureInterval = setInterval(remeasure, LAYOUT_MEASURE_INTERVAL_MS);
732
+ const configInterval = setInterval(() => {
733
+ const next = readPromptStatusOptions();
734
+ if (samePromptStatusOptions(options(), next))
735
+ return;
736
+ const nextScreens = next.quotaStatus.screens;
737
+ const screensChanged = nextScreens.length !== controllerScreens.length ||
738
+ nextScreens.some((screen, position) => screen !== controllerScreens[position]);
739
+ // The swap happens BEFORE the signal that re-renders against it. The
740
+ // other order leaves the line blank until something else happens to
741
+ // change: the render triggered by `setOptions` would read the outgoing
742
+ // pipelines, find none of them serving the incoming screens, subscribe
743
+ // to nothing, and never hear the new pipeline resolve.
744
+ if (screensChanged) {
745
+ for (const controller of controllers)
746
+ controller.dispose();
747
+ controllers = createQuotaControllers(api, solid, nextScreens);
748
+ controllerScreens = nextScreens;
749
+ setRotation(0);
750
+ }
751
+ setOptions(next);
752
+ restartRotation(nextScreens);
753
+ }, CONFIG_POLL_INTERVAL_MS);
754
+ solid.onCleanup(() => {
755
+ clearInterval(configInterval);
756
+ clearInterval(measureInterval);
757
+ if (rotationInterval)
758
+ clearInterval(rotationInterval);
759
+ for (const controller of controllers)
760
+ controller.dispose();
761
+ });
762
+ /**
763
+ * The screen on show right now, picked from the ones that have something to
764
+ * say. A screen with nothing to render is skipped rather than shown blank,
765
+ * which is what lets `resets` sit in the rotation permanently and only
766
+ * appear on the day it matters.
767
+ */
768
+ const current = () => {
769
+ const currentOptions = options();
770
+ if (!showsQuotaForSession({
771
+ audience: currentOptions.quotaStatus.showFor,
772
+ providerID: resolveSessionProviderId(api, sessionID),
773
+ })) {
774
+ return undefined;
775
+ }
776
+ const layout = {
777
+ availableChars: metrics().availableChars,
778
+ maxRows: currentOptions.quotaStatus.rows,
779
+ };
780
+ const rendered = [];
781
+ for (const screen of currentOptions.quotaStatus.screens) {
782
+ const controller = controllers.find((candidate) => candidate.screens.includes(screen));
783
+ if (!controller)
784
+ continue;
785
+ const lines = controller.lines(screen, currentOptions, layout);
786
+ if (lines.length > 0)
787
+ rendered.push({ screen, lines });
788
+ }
789
+ if (rendered.length === 0)
790
+ return undefined;
791
+ return rendered[Math.abs(rotation()) % rendered.length];
792
+ };
793
+ solid.spread(node, {
794
+ get content() {
795
+ // A newline is how one text node becomes two rows: the renderer
796
+ // measures the content it was given, so the node grows to match
797
+ // instead of the slot having to add and remove child nodes.
798
+ return current()?.lines.join("\n") ?? "";
325
799
  },
326
800
  get fg() {
327
- const current = quota();
328
- const tone = resolveQuotaPromptTone(current);
801
+ const screen = current()?.screen;
802
+ const controller = screen
803
+ ? controllers.find((candidate) => candidate.screens.includes(screen))
804
+ : undefined;
805
+ const tone = screen && controller ? controller.tone(screen) : "unknown";
329
806
  if (tone === "danger")
330
807
  return api.theme.current.error;
331
808
  if (tone === "warning" || tone === "stale") {
@@ -335,18 +812,27 @@ function createPromptStatus(api, solid, options) {
335
812
  return api.theme.current.success;
336
813
  return api.theme.current.textMuted;
337
814
  },
815
+ // The host centres this slot against a model label that wraps to two
816
+ // rows on a narrow terminal, which put a one-row line on the bottom
817
+ // row and left the top one empty. Reading starts at the top.
818
+ alignSelf: "flex-start",
338
819
  selectable: false,
339
820
  truncate: true,
340
821
  wrapMode: "none",
341
822
  }, false);
342
823
  return node;
343
824
  }
344
- function showQuotaDetails(api, options) {
825
+ function showQuotaDetails(api) {
826
+ // Read at open time, not at startup: the dialog is one keystroke away from
827
+ // the line it explains, and the two disagreeing about `used` vs `free`
828
+ // after an edit would be worse than either being stale alone.
829
+ const options = readPromptStatusOptions();
345
830
  void refreshQuotaStatus(api).then((status) => {
346
831
  api.ui.dialog.replace(() => api.ui.DialogAlert({
347
832
  title: "Codex quota",
348
833
  message: formatQuotaDetailsText(status, Date.now(), {
349
834
  maskEmail: options.maskEmail && options.maskEmailInQuotaDetails,
835
+ quotaDisplay: options.quotaDisplay,
350
836
  }),
351
837
  onConfirm: () => api.ui.dialog.clear(),
352
838
  }));
@@ -361,11 +847,7 @@ function showQuotaDetails(api, options) {
361
847
  const module = {
362
848
  id: "oc-codex-multi-auth.status",
363
849
  async tui(api) {
364
- const pluginConfig = loadPluginConfig();
365
- const promptOptions = {
366
- maskEmail: getCodexTuiMaskEmail(pluginConfig),
367
- maskEmailInQuotaDetails: getCodexTuiMaskEmailInQuotaDetails(pluginConfig),
368
- };
850
+ const promptOptions = readPromptStatusOptions();
369
851
  const [{ createElement, spread }, { createSignal, onCleanup }] = await Promise.all([import("@opentui/solid"), import("solid-js")]);
370
852
  const solid = {
371
853
  createElement,
@@ -375,7 +857,7 @@ const module = {
375
857
  };
376
858
  api.slots.register({
377
859
  slots: {
378
- session_prompt_right: () => createPromptStatus(api, solid, promptOptions),
860
+ session_prompt_right: (_ctx, props) => createPromptStatus(api, solid, promptOptions, props.session_id),
379
861
  },
380
862
  });
381
863
  const disposeCommand = api.command.register(() => [
@@ -384,7 +866,7 @@ const module = {
384
866
  value: "codex.quota.details",
385
867
  description: "Show active account usage, reset times, source, and last refresh.",
386
868
  category: "Codex",
387
- onSelect: () => showQuotaDetails(api, promptOptions),
869
+ onSelect: () => showQuotaDetails(api),
388
870
  },
389
871
  ]);
390
872
  api.lifecycle.onDispose(disposeCommand);