oc-codex-multi-auth 6.20.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.
Files changed (215) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +729 -568
  3. package/assets/icon.svg +7 -7
  4. package/assets/opencode-logo-ornate-dark.svg +18 -18
  5. package/assets/readme-hero.svg +31 -31
  6. package/config/README.md +177 -177
  7. package/config/minimal-opencode.json +14 -14
  8. package/config/opencode-legacy.json +1496 -1496
  9. package/config/opencode-modern.json +555 -555
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +567 -102
  12. package/dist/index.js.map +1 -1
  13. package/dist/lib/account-display.d.ts +46 -0
  14. package/dist/lib/account-display.d.ts.map +1 -1
  15. package/dist/lib/account-display.js +263 -1
  16. package/dist/lib/account-display.js.map +1 -1
  17. package/dist/lib/accounts/persistence.d.ts +3 -1
  18. package/dist/lib/accounts/persistence.d.ts.map +1 -1
  19. package/dist/lib/accounts/persistence.js +27 -3
  20. package/dist/lib/accounts/persistence.js.map +1 -1
  21. package/dist/lib/accounts/stale-state.d.ts +1 -0
  22. package/dist/lib/accounts/stale-state.d.ts.map +1 -1
  23. package/dist/lib/accounts/stale-state.js +30 -0
  24. package/dist/lib/accounts/stale-state.js.map +1 -1
  25. package/dist/lib/accounts/warm-recovery.d.ts +8 -0
  26. package/dist/lib/accounts/warm-recovery.d.ts.map +1 -0
  27. package/dist/lib/accounts/warm-recovery.js +70 -0
  28. package/dist/lib/accounts/warm-recovery.js.map +1 -0
  29. package/dist/lib/accounts/warm-request.d.ts +2 -0
  30. package/dist/lib/accounts/warm-request.d.ts.map +1 -1
  31. package/dist/lib/accounts/warm-request.js +4 -2
  32. package/dist/lib/accounts/warm-request.js.map +1 -1
  33. package/dist/lib/accounts.d.ts +5 -1
  34. package/dist/lib/accounts.d.ts.map +1 -1
  35. package/dist/lib/accounts.js +22 -17
  36. package/dist/lib/accounts.js.map +1 -1
  37. package/dist/lib/auth/login-runner.d.ts.map +1 -1
  38. package/dist/lib/auth/login-runner.js +79 -2
  39. package/dist/lib/auth/login-runner.js.map +1 -1
  40. package/dist/lib/auto-update-checker.d.ts +17 -1
  41. package/dist/lib/auto-update-checker.d.ts.map +1 -1
  42. package/dist/lib/auto-update-checker.js +49 -3
  43. package/dist/lib/auto-update-checker.js.map +1 -1
  44. package/dist/lib/circuit-breaker.d.ts +5 -0
  45. package/dist/lib/circuit-breaker.d.ts.map +1 -1
  46. package/dist/lib/circuit-breaker.js +23 -6
  47. package/dist/lib/circuit-breaker.js.map +1 -1
  48. package/dist/lib/cli.d.ts +1 -0
  49. package/dist/lib/cli.d.ts.map +1 -1
  50. package/dist/lib/cli.js +9 -3
  51. package/dist/lib/cli.js.map +1 -1
  52. package/dist/lib/codex-reset.d.ts +7 -1
  53. package/dist/lib/codex-reset.d.ts.map +1 -1
  54. package/dist/lib/codex-reset.js +9 -2
  55. package/dist/lib/codex-reset.js.map +1 -1
  56. package/dist/lib/codex-usage.d.ts +7 -4
  57. package/dist/lib/codex-usage.d.ts.map +1 -1
  58. package/dist/lib/codex-usage.js +48 -13
  59. package/dist/lib/codex-usage.js.map +1 -1
  60. package/dist/lib/config.d.ts +77 -1
  61. package/dist/lib/config.d.ts.map +1 -1
  62. package/dist/lib/config.js +190 -15
  63. package/dist/lib/config.js.map +1 -1
  64. package/dist/lib/context-overflow.js +10 -10
  65. package/dist/lib/desktop-notifications.js +6 -6
  66. package/dist/lib/oauth-success.js +202 -202
  67. package/dist/lib/parallel-probe.d.ts.map +1 -1
  68. package/dist/lib/parallel-probe.js +2 -1
  69. package/dist/lib/parallel-probe.js.map +1 -1
  70. package/dist/lib/plan-allotment.d.ts +72 -0
  71. package/dist/lib/plan-allotment.d.ts.map +1 -0
  72. package/dist/lib/plan-allotment.js +142 -0
  73. package/dist/lib/plan-allotment.js.map +1 -0
  74. package/dist/lib/plugin-origin.d.ts +77 -0
  75. package/dist/lib/plugin-origin.d.ts.map +1 -0
  76. package/dist/lib/plugin-origin.js +301 -0
  77. package/dist/lib/plugin-origin.js.map +1 -0
  78. package/dist/lib/prompts/codex-opencode-bridge.js +67 -67
  79. package/dist/lib/prompts/codex.js +75 -75
  80. package/dist/lib/quota-display.d.ts +40 -0
  81. package/dist/lib/quota-display.d.ts.map +1 -0
  82. package/dist/lib/quota-display.js +45 -0
  83. package/dist/lib/quota-display.js.map +1 -0
  84. package/dist/lib/quota-notifications.d.ts +2 -1
  85. package/dist/lib/quota-notifications.d.ts.map +1 -1
  86. package/dist/lib/quota-notifications.js +43 -14
  87. package/dist/lib/quota-notifications.js.map +1 -1
  88. package/dist/lib/quota-overview.d.ts +253 -0
  89. package/dist/lib/quota-overview.d.ts.map +1 -0
  90. package/dist/lib/quota-overview.js +707 -0
  91. package/dist/lib/quota-overview.js.map +1 -0
  92. package/dist/lib/request/fetch-helpers.js +22 -18
  93. package/dist/lib/request/fetch-helpers.js.map +1 -1
  94. package/dist/lib/request/response-handler.d.ts.map +1 -1
  95. package/dist/lib/request/response-handler.js +85 -43
  96. package/dist/lib/request/response-handler.js.map +1 -1
  97. package/dist/lib/request/retry-budget.d.ts +26 -0
  98. package/dist/lib/request/retry-budget.d.ts.map +1 -1
  99. package/dist/lib/request/retry-budget.js +43 -0
  100. package/dist/lib/request/retry-budget.js.map +1 -1
  101. package/dist/lib/rotation.d.ts +1 -1
  102. package/dist/lib/schemas.d.ts +67 -0
  103. package/dist/lib/schemas.d.ts.map +1 -1
  104. package/dist/lib/schemas.js +53 -0
  105. package/dist/lib/schemas.js.map +1 -1
  106. package/dist/lib/storage/backup.d.ts +33 -0
  107. package/dist/lib/storage/backup.d.ts.map +1 -1
  108. package/dist/lib/storage/backup.js +43 -7
  109. package/dist/lib/storage/backup.js.map +1 -1
  110. package/dist/lib/storage/credential-snapshots.d.ts +78 -0
  111. package/dist/lib/storage/credential-snapshots.d.ts.map +1 -0
  112. package/dist/lib/storage/credential-snapshots.js +302 -0
  113. package/dist/lib/storage/credential-snapshots.js.map +1 -0
  114. package/dist/lib/storage/export-import.d.ts.map +1 -1
  115. package/dist/lib/storage/export-import.js +7 -0
  116. package/dist/lib/storage/export-import.js.map +1 -1
  117. package/dist/lib/storage/flagged.d.ts.map +1 -1
  118. package/dist/lib/storage/flagged.js +23 -1
  119. package/dist/lib/storage/flagged.js.map +1 -1
  120. package/dist/lib/storage/load-save.d.ts +5 -0
  121. package/dist/lib/storage/load-save.d.ts.map +1 -1
  122. package/dist/lib/storage/load-save.js +61 -0
  123. package/dist/lib/storage/load-save.js.map +1 -1
  124. package/dist/lib/storage/normalize.d.ts.map +1 -1
  125. package/dist/lib/storage/normalize.js +38 -2
  126. package/dist/lib/storage/normalize.js.map +1 -1
  127. package/dist/lib/storage/paths.d.ts +1 -0
  128. package/dist/lib/storage/paths.d.ts.map +1 -1
  129. package/dist/lib/storage/paths.js +1 -1
  130. package/dist/lib/storage/paths.js.map +1 -1
  131. package/dist/lib/storage/state.d.ts +1 -0
  132. package/dist/lib/storage/state.d.ts.map +1 -1
  133. package/dist/lib/storage/state.js +12 -0
  134. package/dist/lib/storage/state.js.map +1 -1
  135. package/dist/lib/storage/test-home-guard.d.ts +35 -0
  136. package/dist/lib/storage/test-home-guard.d.ts.map +1 -0
  137. package/dist/lib/storage/test-home-guard.js +59 -0
  138. package/dist/lib/storage/test-home-guard.js.map +1 -0
  139. package/dist/lib/tools/codex-dashboard.d.ts.map +1 -1
  140. package/dist/lib/tools/codex-dashboard.js +3 -2
  141. package/dist/lib/tools/codex-dashboard.js.map +1 -1
  142. package/dist/lib/tools/codex-doctor.d.ts.map +1 -1
  143. package/dist/lib/tools/codex-doctor.js +16 -0
  144. package/dist/lib/tools/codex-doctor.js.map +1 -1
  145. package/dist/lib/tools/codex-health.d.ts.map +1 -1
  146. package/dist/lib/tools/codex-health.js +10 -2
  147. package/dist/lib/tools/codex-health.js.map +1 -1
  148. package/dist/lib/tools/codex-label.d.ts.map +1 -1
  149. package/dist/lib/tools/codex-label.js +1 -0
  150. package/dist/lib/tools/codex-label.js.map +1 -1
  151. package/dist/lib/tools/codex-limits.d.ts.map +1 -1
  152. package/dist/lib/tools/codex-limits.js +26 -10
  153. package/dist/lib/tools/codex-limits.js.map +1 -1
  154. package/dist/lib/tools/codex-list.d.ts.map +1 -1
  155. package/dist/lib/tools/codex-list.js +28 -3
  156. package/dist/lib/tools/codex-list.js.map +1 -1
  157. package/dist/lib/tools/codex-note.d.ts.map +1 -1
  158. package/dist/lib/tools/codex-note.js +4 -1
  159. package/dist/lib/tools/codex-note.js.map +1 -1
  160. package/dist/lib/tools/codex-pool.d.ts.map +1 -1
  161. package/dist/lib/tools/codex-pool.js +6 -2
  162. package/dist/lib/tools/codex-pool.js.map +1 -1
  163. package/dist/lib/tools/codex-refresh.d.ts.map +1 -1
  164. package/dist/lib/tools/codex-refresh.js +4 -1
  165. package/dist/lib/tools/codex-refresh.js.map +1 -1
  166. package/dist/lib/tools/codex-remove.d.ts.map +1 -1
  167. package/dist/lib/tools/codex-remove.js +1 -0
  168. package/dist/lib/tools/codex-remove.js.map +1 -1
  169. package/dist/lib/tools/codex-reset.d.ts.map +1 -1
  170. package/dist/lib/tools/codex-reset.js +53 -8
  171. package/dist/lib/tools/codex-reset.js.map +1 -1
  172. package/dist/lib/tools/codex-status.d.ts.map +1 -1
  173. package/dist/lib/tools/codex-status.js +25 -2
  174. package/dist/lib/tools/codex-status.js.map +1 -1
  175. package/dist/lib/tools/codex-switch.d.ts.map +1 -1
  176. package/dist/lib/tools/codex-switch.js +1 -0
  177. package/dist/lib/tools/codex-switch.js.map +1 -1
  178. package/dist/lib/tools/codex-tag.d.ts.map +1 -1
  179. package/dist/lib/tools/codex-tag.js +4 -1
  180. package/dist/lib/tools/codex-tag.js.map +1 -1
  181. package/dist/lib/tools/codex-warm.d.ts +2 -1
  182. package/dist/lib/tools/codex-warm.d.ts.map +1 -1
  183. package/dist/lib/tools/codex-warm.js +49 -5
  184. package/dist/lib/tools/codex-warm.js.map +1 -1
  185. package/dist/lib/tools/index.d.ts +10 -1
  186. package/dist/lib/tools/index.d.ts.map +1 -1
  187. package/dist/lib/tools/index.js.map +1 -1
  188. package/dist/lib/tui-quota-cache.d.ts +35 -0
  189. package/dist/lib/tui-quota-cache.d.ts.map +1 -1
  190. package/dist/lib/tui-quota-cache.js +79 -12
  191. package/dist/lib/tui-quota-cache.js.map +1 -1
  192. package/dist/lib/tui-quota-overview.d.ts +55 -0
  193. package/dist/lib/tui-quota-overview.d.ts.map +1 -0
  194. package/dist/lib/tui-quota-overview.js +209 -0
  195. package/dist/lib/tui-quota-overview.js.map +1 -0
  196. package/dist/lib/tui-status.d.ts +55 -0
  197. package/dist/lib/tui-status.d.ts.map +1 -1
  198. package/dist/lib/tui-status.js +155 -14
  199. package/dist/lib/tui-status.js.map +1 -1
  200. package/dist/lib/ui/auth-menu.d.ts +2 -0
  201. package/dist/lib/ui/auth-menu.d.ts.map +1 -1
  202. package/dist/lib/ui/auth-menu.js +10 -6
  203. package/dist/lib/ui/auth-menu.js.map +1 -1
  204. package/dist/tui.d.ts +59 -1
  205. package/dist/tui.d.ts.map +1 -1
  206. package/dist/tui.js +535 -43
  207. package/dist/tui.js.map +1 -1
  208. package/package.json +155 -155
  209. package/scripts/audit-dev-allowlist.js +114 -114
  210. package/scripts/clean-dist.js +27 -27
  211. package/scripts/copy-oauth-success.js +47 -47
  212. package/scripts/install-oc-codex-multi-auth-core.js +2003 -1469
  213. package/scripts/install-oc-codex-multi-auth.js +37 -37
  214. package/scripts/test-all-models.sh +260 -260
  215. package/scripts/validate-model-map.sh +97 -97
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
  }
@@ -82,10 +136,10 @@ function getSharedQuotaCachePath(api) {
82
136
  async function readSharedQuotaStatus(api, fingerprint) {
83
137
  const cachePath = getSharedQuotaCachePath(api);
84
138
  const snapshot = await readTuiQuotaSnapshot(cachePath);
85
- if (snapshot?.fingerprint === fingerprint)
139
+ if (snapshot && (fingerprint === undefined || snapshot.fingerprint === fingerprint))
86
140
  return snapshot;
87
141
  const fallbackSnapshot = await readTuiQuotaSnapshot();
88
- return fallbackSnapshot?.fingerprint === fingerprint
142
+ return fallbackSnapshot && (fingerprint === undefined || fallbackSnapshot.fingerprint === fingerprint)
89
143
  ? fallbackSnapshot
90
144
  : undefined;
91
145
  }
@@ -98,24 +152,28 @@ async function writeSharedQuotaStatus(api, status) {
98
152
  // not affect the OpenCode TUI.
99
153
  }
100
154
  }
101
- async function resolveActiveQuotaFingerprint() {
155
+ export async function resolveQuotaPollFingerprint(api) {
102
156
  const storage = await loadAccounts();
103
- const selection = storage ? resolveCodexUsageActiveAccount(storage) : null;
157
+ if (!storage || storage.accounts.length === 0)
158
+ return undefined;
159
+ const shared = await readSharedQuotaStatus(api);
160
+ if (shared?.source === "headers" &&
161
+ isFreshTuiQuotaSnapshot(shared) &&
162
+ storage.accounts.some((account) => createUsageAccountFingerprint(account) === shared.fingerprint)) {
163
+ return shared.fingerprint;
164
+ }
165
+ const selection = resolveCodexUsageActiveAccount(storage);
104
166
  return selection
105
167
  ? createUsageAccountFingerprint(selection.account)
106
168
  : undefined;
107
169
  }
108
- async function refreshQuotaStatusInner(api) {
170
+ export async function refreshQuotaStatusInner(api) {
109
171
  try {
110
172
  const storage = await loadAccounts();
111
173
  if (!storage || storage.accounts.length === 0) {
112
174
  return { type: "missing" };
113
175
  }
114
- const selection = resolveCodexUsageActiveAccount(storage);
115
- if (!selection)
116
- return { type: "missing" };
117
- const fingerprint = createUsageAccountFingerprint(selection.account);
118
- const shared = await readSharedQuotaStatus(api, fingerprint);
176
+ const latestShared = await readSharedQuotaStatus(api);
119
177
  const now = Date.now();
120
178
  // A shared snapshot only proves freshness while requests flow: the
121
179
  // request path pushes it per response, but during an idle gap nothing
@@ -123,10 +181,16 @@ async function refreshQuotaStatusInner(api) {
123
181
  // server-side. Trust it as current within one refresh interval;
124
182
  // otherwise fall through to a live /wham/usage read and keep the
125
183
  // snapshot only as a stale fallback.
126
- if (shared && isFreshTuiQuotaSnapshot(shared, now)) {
127
- writeStoredQuotaStatus(api, shared);
128
- return toCompactQuotaStatus(shared, false);
184
+ if (latestShared?.source === "headers" && isFreshTuiQuotaSnapshot(latestShared, now) &&
185
+ storage.accounts.some((account) => createUsageAccountFingerprint(account) === latestShared.fingerprint)) {
186
+ writeStoredQuotaStatus(api, latestShared);
187
+ return toCompactQuotaStatus(latestShared, false);
129
188
  }
189
+ const selection = resolveCodexUsageActiveAccount(storage);
190
+ if (!selection)
191
+ return { type: "missing" };
192
+ const fingerprint = createUsageAccountFingerprint(selection.account);
193
+ const shared = await readSharedQuotaStatus(api, fingerprint);
130
194
  const stored = readStoredQuotaStatus(api, fingerprint);
131
195
  const cached = shared && (!stored || shared.fetchedAt >= stored.fetchedAt)
132
196
  ? shared
@@ -205,7 +269,242 @@ export function shouldRefreshQuotaForEvent(event) {
205
269
  return false;
206
270
  }
207
271
  }
208
- 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) {
209
508
  const [quota, setQuota] = solid.createSignal({
210
509
  type: "loading",
211
510
  });
@@ -244,7 +543,7 @@ function createPromptStatus(api, solid, options) {
244
543
  return;
245
544
  cachePollInFlight = true;
246
545
  void (async () => {
247
- const fingerprint = await resolveActiveQuotaFingerprint();
546
+ const fingerprint = await resolveQuotaPollFingerprint(api);
248
547
  if (!fingerprint) {
249
548
  if (currentFingerprint)
250
549
  applyQuota({ type: "missing" });
@@ -293,29 +592,217 @@ function createPromptStatus(api, solid, options) {
293
592
  if (shouldRefreshQuotaForEvent(event))
294
593
  scheduleRefresh();
295
594
  });
296
- solid.onCleanup(() => {
297
- clearInterval(interval);
298
- clearInterval(accountInterval);
299
- if (refreshTimeout)
300
- clearTimeout(refreshTimeout);
301
- disposeMessageUpdated();
302
- disposeMessagePartUpdated();
303
- disposeSessionIdle();
304
- disposeSessionStatus();
305
- disposeSessionError();
306
- });
307
- const node = solid.createElement("text");
308
- solid.spread(node, {
309
- get content() {
310
- return formatPromptStatusText({
595
+ return {
596
+ screens: ["active"],
597
+ lines(_screen, options) {
598
+ const text = formatPromptStatusText({
311
599
  quota: quota(),
312
600
  width: api.renderer.width,
313
601
  maskEmail: options.maskEmail,
602
+ quotaDisplay: options.quotaDisplay,
314
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") ?? "";
315
799
  },
316
800
  get fg() {
317
- const current = quota();
318
- 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";
319
806
  if (tone === "danger")
320
807
  return api.theme.current.error;
321
808
  if (tone === "warning" || tone === "stale") {
@@ -325,18 +812,27 @@ function createPromptStatus(api, solid, options) {
325
812
  return api.theme.current.success;
326
813
  return api.theme.current.textMuted;
327
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",
328
819
  selectable: false,
329
820
  truncate: true,
330
821
  wrapMode: "none",
331
822
  }, false);
332
823
  return node;
333
824
  }
334
- 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();
335
830
  void refreshQuotaStatus(api).then((status) => {
336
831
  api.ui.dialog.replace(() => api.ui.DialogAlert({
337
832
  title: "Codex quota",
338
833
  message: formatQuotaDetailsText(status, Date.now(), {
339
834
  maskEmail: options.maskEmail && options.maskEmailInQuotaDetails,
835
+ quotaDisplay: options.quotaDisplay,
340
836
  }),
341
837
  onConfirm: () => api.ui.dialog.clear(),
342
838
  }));
@@ -351,11 +847,7 @@ function showQuotaDetails(api, options) {
351
847
  const module = {
352
848
  id: "oc-codex-multi-auth.status",
353
849
  async tui(api) {
354
- const pluginConfig = loadPluginConfig();
355
- const promptOptions = {
356
- maskEmail: getCodexTuiMaskEmail(pluginConfig),
357
- maskEmailInQuotaDetails: getCodexTuiMaskEmailInQuotaDetails(pluginConfig),
358
- };
850
+ const promptOptions = readPromptStatusOptions();
359
851
  const [{ createElement, spread }, { createSignal, onCleanup }] = await Promise.all([import("@opentui/solid"), import("solid-js")]);
360
852
  const solid = {
361
853
  createElement,
@@ -365,7 +857,7 @@ const module = {
365
857
  };
366
858
  api.slots.register({
367
859
  slots: {
368
- session_prompt_right: () => createPromptStatus(api, solid, promptOptions),
860
+ session_prompt_right: (_ctx, props) => createPromptStatus(api, solid, promptOptions, props.session_id),
369
861
  },
370
862
  });
371
863
  const disposeCommand = api.command.register(() => [
@@ -374,7 +866,7 @@ const module = {
374
866
  value: "codex.quota.details",
375
867
  description: "Show active account usage, reset times, source, and last refresh.",
376
868
  category: "Codex",
377
- onSelect: () => showQuotaDetails(api, promptOptions),
869
+ onSelect: () => showQuotaDetails(api),
378
870
  },
379
871
  ]);
380
872
  api.lifecycle.onDispose(disposeCommand);