@cortexkit/opencode-openai-auth 1.3.0 → 1.4.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 (85) hide show
  1. package/dist/bin.js +8 -8
  2. package/dist/{codex-request-B9D0e97Q.d.ts → codex-request-hw0XcldE.d.ts} +27 -27
  3. package/dist/index-1eq2s1cp.js +2 -0
  4. package/dist/{index-m3ctrabv.js → index-1tdnrt68.js} +1 -1
  5. package/dist/{index-8x1aj7q3.js → index-5230x6s8.js} +2 -2
  6. package/dist/{index-gn2b097f.js → index-ag04nyv7.js} +7 -7
  7. package/dist/index-brbqqer5.js +90 -0
  8. package/dist/{index-xwvze96g.js → index-d98f1xdp.js} +1 -1
  9. package/dist/index-jcyrm58f.js +65 -0
  10. package/dist/{index-2pgkrebq.js → index-rsvnabbe.js} +2 -2
  11. package/dist/{index-ftkhhr6d.js → index-s88n2bjn.js} +1 -1
  12. package/dist/{index-bc89akak.js → index-t5m0dhmq.js} +1 -1
  13. package/dist/index-vy729a0b.js +69 -0
  14. package/dist/index-wgwze391.js +2 -0
  15. package/dist/{index-tddr876f.js → index-yhw3hnsh.js} +2 -2
  16. package/dist/index.d.ts +11 -3
  17. package/dist/index.js +1 -1
  18. package/dist/{model-BdITTmeb.d.ts → model-DphbMQce.d.ts} +1 -1
  19. package/dist/{port-file-DbHIVC9a.d.ts → port-file-Cx-wwe5Q.d.ts} +1 -1
  20. package/dist/{protocol-MysYPAho.d.ts → protocol-CDqdjh74.d.ts} +1 -1
  21. package/dist/rpc/port-file.d.ts +1 -1
  22. package/dist/rpc/port-file.js +1 -1
  23. package/dist/rpc/protocol.d.ts +1 -1
  24. package/dist/rpc/protocol.js +1 -1
  25. package/dist/rpc/rpc-client.d.ts +3 -3
  26. package/dist/rpc/rpc-client.js +1 -1
  27. package/dist/rpc/rpc-dir.js +1 -1
  28. package/dist/{sidebar-state-CVSok7zv.d.ts → sidebar-state-pTd9gg9W.d.ts} +55 -7
  29. package/dist/sidebar-state.d.ts +2 -2
  30. package/dist/sidebar-state.js +1 -1
  31. package/dist/{token-z6qq7a1d.js → token-0z5g59p8.js} +1 -1
  32. package/dist/token-util-jf9g6stk.js +1 -0
  33. package/dist/tui-preferences.js +1 -1
  34. package/dist/tui.d.ts +30 -3
  35. package/dist/v2/server.d.ts +2 -2
  36. package/dist/v2/server.js +2 -2
  37. package/package.json +4 -4
  38. package/src/tui-compiled/raw/shared/0332701351b3eeec-format.js +50 -5
  39. package/src/tui-compiled/raw/shared/0f77ebc3c914c9c6-custody.js +70 -1
  40. package/src/tui-compiled/raw/shared/169b2b4bf30909bc-consumer.js +88 -61
  41. package/src/tui-compiled/raw/shared/2577ec69f9973ae9-quota-normalize.js +45 -1
  42. package/src/tui-compiled/raw/shared/30f5bc480f31f51c-roster.js +100 -43
  43. package/src/tui-compiled/raw/shared/39c3cb05738fcf6d-projection.js +3 -1
  44. package/src/tui-compiled/raw/shared/42ae93ab554fcec2-open-url.js +7 -9
  45. package/src/tui-compiled/raw/shared/4825e03209d6ae3e-with-lock.js +2 -0
  46. package/src/tui-compiled/raw/shared/4f36d333d081d040-merge.js +17 -3
  47. package/src/tui-compiled/raw/shared/81dddd203f1b92a3-quota-writer.js +253 -0
  48. package/src/tui-compiled/raw/shared/905bffc0220f43d0-map.js +31 -6
  49. package/src/tui-compiled/raw/shared/abd062d633e29fd2-pool-routing.js +116 -12
  50. package/src/tui-compiled/raw/shared/bfbdd0f9aae56b47-pool-quota.js +15 -3
  51. package/src/tui-compiled/raw/shared/d0f0b6be200311af-insula.js +64 -2
  52. package/src/tui-compiled/raw/shared/d5a8f1061ee2f114-index.js +2 -2
  53. package/src/tui-compiled/raw/shared/dbc61cd405941828-accounts.js +5 -4
  54. package/src/tui-compiled/raw/shared/dc9290c2a7552ba6-watch.js +2 -1
  55. package/src/tui-compiled/raw/shared/e8d3c8da776ef9cd-index.js +1 -0
  56. package/src/tui-compiled/raw/sidebar-state.js +32 -3
  57. package/src/tui-compiled/raw/tui/v2-dialog.js +2 -1
  58. package/src/tui-compiled/raw/tui.tsx +170 -26
  59. package/src/tui-compiled/runtime/shared/0332701351b3eeec-format.js +50 -5
  60. package/src/tui-compiled/runtime/shared/0f77ebc3c914c9c6-custody.js +70 -1
  61. package/src/tui-compiled/runtime/shared/169b2b4bf30909bc-consumer.js +88 -61
  62. package/src/tui-compiled/runtime/shared/2577ec69f9973ae9-quota-normalize.js +45 -1
  63. package/src/tui-compiled/runtime/shared/30f5bc480f31f51c-roster.js +100 -43
  64. package/src/tui-compiled/runtime/shared/39c3cb05738fcf6d-projection.js +3 -1
  65. package/src/tui-compiled/runtime/shared/42ae93ab554fcec2-open-url.js +7 -9
  66. package/src/tui-compiled/runtime/shared/4825e03209d6ae3e-with-lock.js +2 -0
  67. package/src/tui-compiled/runtime/shared/4f36d333d081d040-merge.js +17 -3
  68. package/src/tui-compiled/runtime/shared/81dddd203f1b92a3-quota-writer.js +253 -0
  69. package/src/tui-compiled/runtime/shared/905bffc0220f43d0-map.js +31 -6
  70. package/src/tui-compiled/runtime/shared/abd062d633e29fd2-pool-routing.js +116 -12
  71. package/src/tui-compiled/runtime/shared/bfbdd0f9aae56b47-pool-quota.js +15 -3
  72. package/src/tui-compiled/runtime/shared/d0f0b6be200311af-insula.js +64 -2
  73. package/src/tui-compiled/runtime/shared/d5a8f1061ee2f114-index.js +2 -2
  74. package/src/tui-compiled/runtime/shared/dbc61cd405941828-accounts.js +5 -4
  75. package/src/tui-compiled/runtime/shared/dc9290c2a7552ba6-watch.js +2 -1
  76. package/src/tui-compiled/runtime/shared/e8d3c8da776ef9cd-index.js +1 -0
  77. package/src/tui-compiled/runtime/sidebar-state.js +32 -3
  78. package/src/tui-compiled/runtime/tui/v2-dialog.js +2 -1
  79. package/src/tui-compiled/runtime/tui.js +350 -219
  80. package/dist/index-1mbgsxsr.js +0 -2
  81. package/dist/index-4v2fs42x.js +0 -65
  82. package/dist/index-ex6nx3mq.js +0 -2
  83. package/dist/index-psjyexj3.js +0 -69
  84. package/dist/index-tn8x5tz8.js +0 -90
  85. package/dist/token-util-cf0nyh7n.js +0 -1
@@ -128,6 +128,9 @@ export function insulaUsageTransport(options) {
128
128
  }
129
129
  const MINUTE = 60_000;
130
130
  const MAX_AGE = 4 * MINUTE;
131
+ // consumer-contract.md:331 refetches a slot after 60 s. Two such intervals
132
+ // bound our willingness to wait, not a claim that insula completed its sweep.
133
+ const COLD_WAIT = 2 * MINUTE;
131
134
  const sources = new Map();
132
135
  // Keep only the minute budget after the last owner closes, so project churn
133
136
  // cannot reset the bound. Expired budgets are pruned on the next tick.
@@ -170,6 +173,9 @@ export class InsulaQuotaSource {
170
173
  transport;
171
174
  key;
172
175
  #entries = [];
176
+ #available = false;
177
+ #completeProviders = new Set();
178
+ #coldSince = new Map();
173
179
  /** Every usage.get, polled or watch-driven, shares this one slot. */
174
180
  #reads = new CoalescedRead(async () => {
175
181
  try {
@@ -178,13 +184,25 @@ export class InsulaQuotaSource {
178
184
  !Array.isArray(reply.result) ||
179
185
  reply.error !== undefined)
180
186
  throw new Error('unavailable insula usage');
181
- // Each response is a snapshot, not a delta. Missing accounts fall back;
182
- // neither pending nor the entry count asserts provider completeness.
187
+ // Each response is a snapshot, not a delta. Only completeProviders
188
+ // asserts completeness; neither pending nor the entry count does.
183
189
  this.#entries = reply.result.filter((entry) => record(entry) && typeof entry.provider === 'string');
190
+ this.#available =
191
+ this.#entries.length === reply.result.length &&
192
+ (reply.completeProviders === undefined ||
193
+ (Array.isArray(reply.completeProviders) &&
194
+ reply.completeProviders.every((value) => typeof value === 'string')));
195
+ this.#completeProviders = new Set(this.#available && Array.isArray(reply.completeProviders)
196
+ ? reply.completeProviders
197
+ : []);
198
+ for (const provider of this.#completeProviders)
199
+ this.#coldSince.delete(provider);
184
200
  }
185
201
  catch {
186
202
  // Refusal is absence, even if the previous reading was still young.
187
203
  this.#entries = [];
204
+ this.#available = false;
205
+ this.#completeProviders.clear();
188
206
  }
189
207
  }, () => { });
190
208
  #reader = new ChangeNoticeReader();
@@ -391,6 +409,50 @@ export class InsulaQuotaSource {
391
409
  }
392
410
  return undefined;
393
411
  }
412
+ /** Cache-only background lookup; current() retains its original miss behavior. */
413
+ lookup(provider, accountId, now) {
414
+ if (!this.#available)
415
+ return { miss: 'unreachable' };
416
+ const entry = this.#entries.find((value) => value.provider === provider &&
417
+ typeof value.account === 'string' &&
418
+ value.account.trim() === accountId);
419
+ // An unlabelled error describes the provider's credential path, not an
420
+ // unrelated labelled account. Never borrow another account's verdict.
421
+ const errorClass = (entry ??
422
+ this.#entries.find((value) => value.provider === provider &&
423
+ value.account === undefined &&
424
+ value.errorClass !== undefined))?.errorClass;
425
+ if (errorClass === 'upstream_failed')
426
+ return { miss: 'upstream-failed' };
427
+ if (errorClass === 'credential_absent' ||
428
+ errorClass === 'credential_unusable' ||
429
+ errorClass === 'credential_rejected')
430
+ return { miss: 'credential' };
431
+ if (errorClass !== undefined)
432
+ return { miss: 'unreachable' };
433
+ if (entry) {
434
+ const fetchedAt = typeof entry.fetchedAt === 'string'
435
+ ? Date.parse(entry.fetchedAt)
436
+ : Number.NaN;
437
+ if (!record(entry.usage) || !Number.isFinite(fetchedAt))
438
+ return { miss: 'unreachable' };
439
+ const reading = {
440
+ entry,
441
+ context: {
442
+ fetchedAt,
443
+ ...(typeof entry.source === 'string' && { source: entry.source }),
444
+ },
445
+ };
446
+ return now >= fetchedAt + MAX_AGE || entry.stale !== undefined
447
+ ? { miss: 'stale', reading }
448
+ : { reading };
449
+ }
450
+ if (this.#completeProviders.has(provider))
451
+ return { miss: 'absent' };
452
+ const since = this.#coldSince.get(provider) ?? now;
453
+ this.#coldSince.set(provider, since);
454
+ return { miss: now - since >= COLD_WAIT ? 'unreachable' : 'cold' };
455
+ }
394
456
  }
395
457
  export function acquireInsulaQuotaSource(transport, tokenPath) {
396
458
  const key = JSON.stringify([
@@ -1,5 +1,5 @@
1
1
  export { quotaCodec } from "./053cc17b5419261e-codec.js";
2
- export { formatQuota, isQuotaStale, NO_LIMITS_REPORTED, NO_QUOTA_READING, QUOTA_STALE_AFTER_MS, quotaTextParts, quotaWindowName, quotaWindowParts, } from "./0332701351b3eeec-format.js";
3
- export { ALL_SCOPE, DEFAULT_REQUIRED_LABELS, decodeQuotaMap, emptyQuotaMap, isQuotaMap, } from "./905bffc0220f43d0-map.js";
2
+ export { formatQuota, isQuotaStale, NO_LIMITS_REPORTED, NO_QUOTA_READING, QUOTA_STALE_AFTER_MS, quotaCreditsText, quotaTextParts, quotaWindowName, quotaWindowParts, } from "./0332701351b3eeec-format.js";
3
+ export { ALL_SCOPE, DEFAULT_REQUIRED_LABELS, decodeQuotaMap, emptyQuotaMap, isQuotaCredits, isQuotaMap, } from "./905bffc0220f43d0-map.js";
4
4
  export { isQuotaObservation, mergeQuotaObservation, QuotaCodecError, } from "./4f36d333d081d040-merge.js";
5
5
  export { budgetExhaustedResetAt, futureResetAt, projectQuota, readsExhausted, } from "./39c3cb05738fcf6d-projection.js";
@@ -154,13 +154,14 @@ function normalizeQuota(value) {
154
154
  };
155
155
  }
156
156
  const credits = value.credits;
157
- if (isRecord(credits) && typeof credits.hasCredits === "boolean" && typeof credits.unlimited === "boolean" && typeof credits.overageLimitReached === "boolean") {
158
- const balance = Number(credits.balance);
157
+ if (isRecord(credits) && typeof credits.hasCredits === "boolean" && typeof credits.unlimited === "boolean" && (credits.overageLimitReached === void 0 || typeof credits.overageLimitReached === "boolean")) {
158
+ const balance = Number(credits.balance), checkedAt = typeof credits.checkedAt === "number" ? credits.checkedAt : Number.NaN;
159
159
  quota.credits = {
160
160
  hasCredits: credits.hasCredits,
161
161
  unlimited: credits.unlimited,
162
- overageLimitReached: credits.overageLimitReached,
163
- ...Number.isFinite(balance) ? { balance } : {}
162
+ ...typeof credits.overageLimitReached === "boolean" ? { overageLimitReached: credits.overageLimitReached } : {},
163
+ ...Number.isFinite(balance) ? { balance } : {},
164
+ ...Number.isFinite(checkedAt) ? { checkedAt } : {}
164
165
  };
165
166
  }
166
167
  return Object.keys(quota).length ? quota : void 0;
@@ -109,8 +109,9 @@ export class ScopedRosterWatch {
109
109
  this.#reads = new CoalescedRead(async () => {
110
110
  const force = this.#force;
111
111
  this.#force = false;
112
+ // A read is only ever requested by a notice, which set #latestView.
112
113
  if (force || this.#latestView !== this.#options.view())
113
- await this.#options.discover();
114
+ await this.#options.discover(this.#latestView ?? '');
114
115
  }, (error) => {
115
116
  if (!this.#stopped)
116
117
  this.#options.onError?.(error);
@@ -14,6 +14,7 @@ export { ClaustrumConsumerError, } from "./ca0fccaba1d87af4-errors.js";
14
14
  export { assertHostSlotMatchesMode, assertNotCustodyPlaceholder, CUSTODY_PLACEHOLDER_PREFIX, classifyHostSlot, custodyPlaceholder, custodyPlaceholderKey, isCustodyPlaceholder, isCustodyPlaceholderValue, } from "./d9d2892a4c371301-host-slot.js";
15
15
  export { acquireInsulaQuotaSource, } from "./d0f0b6be200311af-insula.js";
16
16
  export { acceptAccount, declineAccount, isDeclined, } from "./7c140680fc81be92-interlock.js";
17
+ export { drainVaultQuota } from "./81dddd203f1b92a3-quota-writer.js";
17
18
  export { acceptVaultRoute, DEFAULT_ROUTE_PREFIX, declineVaultRoute, mutateVaultRoster, projectVaultRoster, readVaultRoster, recordVaultAccountQuota, recordVaultQuota, refreshVaultRoster, resolveVaultPrimary, vaultRoutingRows, } from "./30f5bc480f31f51c-roster.js";
18
19
  export { ChangeNoticeReader, RESUBSCRIBE_BACKOFF_MS, SAFETY_POLL_MS, ScopedRosterWatch, WATCH_RETRY_MS, } from "./dc9290c2a7552ba6-watch.js";
19
20
  /**
@@ -2,13 +2,35 @@ import {
2
2
  formatQuota,
3
3
  isQuotaStale,
4
4
  projectQuota,
5
+ quotaCreditsText,
5
6
  quotaWindowName
6
7
  } from "./shared/d5a8f1061ee2f114-index.js";
7
8
  import { orderForPlacement } from "./shared/3e7d45a77c6ad4d5-index.js";
8
9
  import {
10
+ creditsUsable,
9
11
  orderedPlacement,
10
12
  vaultMainRouteId
11
13
  } from "./shared/abd062d633e29fd2-pool-routing.js";
14
+ export function accountCreditsText(quota) {
15
+ const credits = quota?.credits;
16
+ if (credits === null || typeof credits !== "object")
17
+ return;
18
+ const { hasCredits, unlimited, balance } = credits;
19
+ if (typeof hasCredits !== "boolean" || typeof unlimited !== "boolean")
20
+ return;
21
+ return quotaCreditsText({
22
+ hasCredits,
23
+ unlimited,
24
+ ...typeof balance === "number" ? { balance } : {}
25
+ });
26
+ }
27
+ export function accountServesOnCredits(quota, now = Date.now()) {
28
+ if (!exhaustedQuotaResetAt(quota, now))
29
+ return !1;
30
+ if (spendControlExhaustedResetAt(quota, now))
31
+ return !1;
32
+ return creditsUsable(quota?.credits, now);
33
+ }
12
34
  const QUOTA_WINDOW_KEYS = ["primary", "secondary"], LEGACY_WINDOW_MINUTES = {
13
35
  primary: 300,
14
36
  secondary: 10080
@@ -153,7 +175,7 @@ export function normalizeSidebarState(raw) {
153
175
  };
154
176
  } else
155
177
  main = { quota: null, killed: !1 };
156
- const fallbacks = normalizeAccountList(r.fallbacks), vaultAccounts = Array.isArray(r.vaultAccounts) ? normalizeAccountList(r.vaultAccounts) : void 0, activeId = typeof r.activeId === "string" ? r.activeId : void 0, route = typeof r.route === "string" ? r.route : DEFAULT_SIDEBAR_STATE.route, lastUpdated = typeof r.lastUpdated === "number" ? r.lastUpdated : 0, planType = typeof r.planType === "string" ? r.planType : void 0, credits = typeof r.credits === "number" ? r.credits : void 0, activeRouting = normalizeActiveRouting(r.activeRouting), stickyAssignments = normalizeStickyAssignments(r.stickyAssignments);
178
+ const fallbacks = normalizeAccountList(r.fallbacks), vaultAccounts = Array.isArray(r.vaultAccounts) ? normalizeAccountList(r.vaultAccounts) : void 0, activeId = typeof r.activeId === "string" ? r.activeId : void 0, route = typeof r.route === "string" ? r.route : DEFAULT_SIDEBAR_STATE.route, lastUpdated = typeof r.lastUpdated === "number" ? r.lastUpdated : 0, planType = typeof r.planType === "string" ? r.planType : void 0, activeRouting = normalizeActiveRouting(r.activeRouting), stickyAssignments = normalizeStickyAssignments(r.stickyAssignments);
157
179
  return {
158
180
  main,
159
181
  fallbacks,
@@ -163,7 +185,6 @@ export function normalizeSidebarState(raw) {
163
185
  ...activeRouting !== void 0 ? { activeRouting } : {},
164
186
  ...stickyAssignments !== void 0 ? { stickyAssignments } : {},
165
187
  ...planType !== void 0 ? { planType } : {},
166
- ...credits !== void 0 ? { credits } : {},
167
188
  ...r.accountPool === !0 ? { accountPool: !0 } : {},
168
189
  ...vaultAccounts !== void 0 ? { vaultAccounts } : {}
169
190
  };
@@ -491,12 +512,18 @@ function mergeSpendControl(fresher, older) {
491
512
  return;
492
513
  return older.spendControl;
493
514
  }
515
+ function fresherCredits(incoming, existing) {
516
+ if (!incoming || !existing)
517
+ return incoming ?? existing;
518
+ const at = (credits) => typeof credits.checkedAt === "number" && Number.isFinite(credits.checkedAt) ? credits.checkedAt : Number.NEGATIVE_INFINITY;
519
+ return at(existing) > at(incoming) ? existing : incoming;
520
+ }
494
521
  function mergeQuotaByWindow(incoming, existing) {
495
522
  if (!incoming)
496
523
  return existing;
497
524
  if (!existing)
498
525
  return incoming;
499
- const incomingAt = latestQuotaCheckedAt(incoming), existingAt = latestQuotaCheckedAt(existing), existingSnapshotIsFresher = existingAt !== void 0 && (incomingAt === void 0 || existingAt > incomingAt), primary = freshestWindow(incoming.primary, existing.primary, existingSnapshotIsFresher, incoming.checkedAt, existing.checkedAt), secondary = freshestWindow(incoming.secondary, existing.secondary, existingSnapshotIsFresher, incoming.checkedAt, existing.checkedAt), spendControl = existingSnapshotIsFresher ? mergeSpendControl(existing, incoming) : mergeSpendControl(incoming, existing), spendControlCleared = spendControl === void 0 && (incoming.spendControlCleared === !0 || existing.spendControlCleared === !0);
526
+ const incomingAt = latestQuotaCheckedAt(incoming), existingAt = latestQuotaCheckedAt(existing), existingSnapshotIsFresher = existingAt !== void 0 && (incomingAt === void 0 || existingAt > incomingAt), primary = freshestWindow(incoming.primary, existing.primary, existingSnapshotIsFresher, incoming.checkedAt, existing.checkedAt), secondary = freshestWindow(incoming.secondary, existing.secondary, existingSnapshotIsFresher, incoming.checkedAt, existing.checkedAt), spendControl = existingSnapshotIsFresher ? mergeSpendControl(existing, incoming) : mergeSpendControl(incoming, existing), spendControlCleared = spendControl === void 0 && (incoming.spendControlCleared === !0 || existing.spendControlCleared === !0), credits = fresherCredits(incoming.credits, existing.credits);
500
527
  let checkedAt;
501
528
  for (const stamp of [
502
529
  finiteWindowCheckedAt(primary),
@@ -507,6 +534,7 @@ function mergeQuotaByWindow(incoming, existing) {
507
534
  const {
508
535
  spendControl: _incomingSpendControl,
509
536
  spendControlCleared: _incomingCleared,
537
+ credits: _incomingCredits,
510
538
  ...incomingRest
511
539
  } = incoming;
512
540
  return {
@@ -515,6 +543,7 @@ function mergeQuotaByWindow(incoming, existing) {
515
543
  secondary,
516
544
  ...spendControl !== void 0 ? { spendControl } : {},
517
545
  ...spendControlCleared ? { spendControlCleared: !0 } : {},
546
+ ...credits !== void 0 ? { credits } : {},
518
547
  checkedAt: checkedAt ?? incoming.checkedAt
519
548
  };
520
549
  }
@@ -22,7 +22,8 @@ export async function openControlDialog(dialog, initial, apply, active = () => !
22
22
  const choices = [
23
23
  ...section.items.filter((item) => item.actions.length > 0).map((item) => ({
24
24
  title: item.label,
25
- description: inlineDescription(item.status, item.detail),
25
+ description: inlineDescription(item.detail),
26
+ ...item.status ? { footer: item.status } : {},
26
27
  value: { itemId: item.id, actions: item.actions }
27
28
  })),
28
29
  ...section.actions.map((action) => ({
@@ -16,6 +16,7 @@ import type {
16
16
  } from "@opencode-ai/plugin/tui"
17
17
  import {
18
18
  createEffect,
19
+ createMemo,
19
20
  createSignal,
20
21
  For,
21
22
  Index,
@@ -28,6 +29,8 @@ import { createRpcClient } from "./rpc/rpc-client.js"
28
29
  import { resolveRpcDir } from "./rpc/rpc-dir.js"
29
30
  import {
30
31
  type AccountQuota,
32
+ accountCreditsText,
33
+ accountServesOnCredits,
31
34
  type CollapsedAccountLine,
32
35
  computeQuotaPacing,
33
36
  DEFAULT_SIDEBAR_STATE,
@@ -254,6 +257,45 @@ function StatRow(props: {
254
257
  )
255
258
  }
256
259
 
260
+ // One of an account's own rows under its quota bars (budget amounts, prepaid
261
+ // credits, reset credits): muted label left, value right. Like the quota rows
262
+ // it stays on one line: it measures its own width and, when both do not fit,
263
+ // cuts only the label (with …), keeping one blank cell before the value.
264
+ function AccountMetaRow(props: {
265
+ theme: ThemeCurrent
266
+ label: string
267
+ value: string
268
+ }) {
269
+ const [width, setWidth] = createSignal(0)
270
+ let row: { width: number } | undefined
271
+ const label = () => {
272
+ const columns = width()
273
+ if (columns <= 0) return props.label
274
+ return fitCollapsedLabel(
275
+ props.label,
276
+ columns - displayWidth(props.value) - 1,
277
+ )
278
+ }
279
+ return (
280
+ <box
281
+ ref={(element: { width: number }) => {
282
+ row = element
283
+ }}
284
+ onSizeChange={() => setWidth(row?.width ?? 0)}
285
+ width='100%'
286
+ flexDirection='row'
287
+ justifyContent='space-between'
288
+ >
289
+ <text fg={props.theme.textMuted} wrapMode='none' flexShrink={1}>
290
+ {label()}
291
+ </text>
292
+ <text fg={props.theme.text} wrapMode='none' flexShrink={0}>
293
+ <b>{props.value}</b>
294
+ </text>
295
+ </box>
296
+ )
297
+ }
298
+
257
299
  // One account in the collapsed view: label left; narrow quota text and the
258
300
  // status dot right (red ⊘ when killed). The session's account keeps the
259
301
  // usage tone; other accounts, and any account whose reading is stale, are
@@ -475,9 +517,6 @@ export function getQuotaMetadataRows(
475
517
  ): Array<{ label: string; value: string }> {
476
518
  const rows: Array<{ label: string; value: string }> = []
477
519
  if (state.planType) rows.push({ label: 'Plan', value: state.planType })
478
- if (state.credits !== undefined) {
479
- rows.push({ label: 'Credits', value: String(state.credits) })
480
- }
481
520
  return rows
482
521
  }
483
522
 
@@ -499,9 +538,18 @@ export function formatSpendControlAmounts(
499
538
  return `${formatGroupedAmount(spendControl.used)} / ${formatGroupedAmount(spendControl.limit)} ${unit}`
500
539
  }
501
540
 
541
+ /**
542
+ * The account's own rows under its quota bars: the workspace budget's amounts
543
+ * (`credits`), the prepaid credits (`prepaid`, worded by `accountCreditsText`)
544
+ * and the reset-credit count (`resets`), each only when known. While the
545
+ * account is serving the session on its prepaid credits (its free quota is
546
+ * used up), the prepaid row says so in place of its `prepaid` label.
547
+ */
502
548
  export function getAccountMetadataRows(
503
549
  resetCredits: number | undefined,
504
550
  spendControl?: SpendControlReading,
551
+ prepaidCredits?: string,
552
+ servingOnCredits = false,
505
553
  ): Array<{ label: string; value: string }> {
506
554
  const rows: Array<{ label: string; value: string }> = []
507
555
  if (spendControl) {
@@ -510,16 +558,64 @@ export function getAccountMetadataRows(
510
558
  value: formatSpendControlAmounts(spendControl),
511
559
  })
512
560
  }
561
+ if (prepaidCredits !== undefined) {
562
+ rows.push({
563
+ label: servingOnCredits ? 'serving on credits' : 'prepaid',
564
+ value: prepaidCredits,
565
+ })
566
+ }
513
567
  if (resetCredits !== undefined) {
514
568
  rows.push({ label: 'resets', value: String(resetCredits) })
515
569
  }
516
570
  return rows
517
571
  }
518
572
 
573
+ /** The fewest cells a shortened quota bar keeps; below this it is dropped. */
574
+ const MIN_QUOTA_BAR_CELLS = 3
575
+
576
+ export interface QuotaRowFit {
577
+ /** Cells of usage bar to draw; 0 draws no bar. */
578
+ barCells: number
579
+ showReset: boolean
580
+ }
581
+
582
+ /**
583
+ * How an expanded quota row fits on one line `columns` cells wide. The row is
584
+ * the padded label, the bar, the " N% left" figure and, at the right edge, the
585
+ * reset time. When it does not fit, the reset time goes first, then the bar
586
+ * shrinks (to no fewer than MIN_QUOTA_BAR_CELLS cells, else it is dropped).
587
+ * The label and the figure are never cut. `columns` of 0 means the row has not
588
+ * been measured yet, and everything is drawn.
589
+ */
590
+ export function fitQuotaRow(input: {
591
+ columns: number
592
+ label: string
593
+ barWidth: number
594
+ figure: string
595
+ resetText?: string
596
+ }): QuotaRowFit {
597
+ const { columns, barWidth } = input
598
+ const reset = input.resetText ? displayWidth(input.resetText) : 0
599
+ if (columns <= 0) return { barCells: barWidth, showReset: reset > 0 }
600
+ const fixed = displayWidth(input.label) + displayWidth(input.figure)
601
+ // The reset sits at the right edge with at least one blank cell before it,
602
+ // so it never runs into the figure.
603
+ if (reset > 0 && fixed + barWidth + 1 + reset <= columns)
604
+ return { barCells: barWidth, showReset: true }
605
+ const room = columns - fixed
606
+ if (room >= barWidth) return { barCells: barWidth, showReset: false }
607
+ return {
608
+ barCells: room >= MIN_QUOTA_BAR_CELLS ? room : 0,
609
+ showReset: false,
610
+ }
611
+ }
612
+
519
613
  // Quota window row: muted label left, tone-colored usage bar and shared quota text.
520
614
  // When pacing data is present, the bar
521
615
  // gains a pace segment and an off-pace window adds a muted subline with the
522
616
  // reserve/deficit delta and the projected runout.
617
+ // The row measures its own width and never wraps: fitQuotaRow decides what is
618
+ // left out when it is too narrow, and the pacing subline is cut with ….
523
619
  function QuotaRow(props: {
524
620
  theme: ThemeCurrent
525
621
  appearance: AppearancePrefs
@@ -531,6 +627,29 @@ function QuotaRow(props: {
531
627
  pacing: QuotaPacing | null
532
628
  }) {
533
629
  const used = () => props.window?.usedPercent ?? 0
630
+ const [columns, setColumns] = createSignal(0)
631
+ let row: { width: number } | undefined
632
+ const label = () => props.label.padEnd(props.labelWidth)
633
+ const figure = () => ` ${props.text.slice(props.label.length + 1)}`
634
+ const fit = () =>
635
+ fitQuotaRow({
636
+ columns: columns(),
637
+ label: label(),
638
+ barWidth: props.appearance.barWidth,
639
+ figure: figure(),
640
+ resetText: props.resetText,
641
+ })
642
+ // Memos, so a new measured width that leaves the fit unchanged (as the
643
+ // first measurement usually does) does not rebuild the bar's text nodes:
644
+ // rebuilt nodes are blank until the next frame is drawn.
645
+ const barCells = createMemo(() => fit().barCells)
646
+ const showReset = createMemo(() => fit().showReset)
647
+ const fittedPaceLine = () => {
648
+ const line = paceLine()
649
+ if (line === null || columns() <= 0) return line
650
+ // The subline is indented by the label column; cut it to the rest.
651
+ return fitCollapsedLabel(line, columns() - props.labelWidth)
652
+ }
534
653
  const paceLine = () => {
535
654
  const pacing = props.pacing
536
655
  if (!pacing || pacing.state === 'on-pace') return null
@@ -553,40 +672,64 @@ function QuotaRow(props: {
553
672
  }
554
673
  >
555
674
  {/* Bars show usage; the adjacent figure shows quota left, as in the dialog. */}
556
- <box width='100%' flexDirection='row' justifyContent='space-between'>
557
- <box flexDirection='row'>
558
- <text fg={props.theme.textMuted}>
559
- {props.label.padEnd(props.labelWidth)}
675
+ <box
676
+ ref={(element: { width: number }) => {
677
+ row = element
678
+ }}
679
+ onSizeChange={() => setColumns(row?.width ?? 0)}
680
+ width='100%'
681
+ flexDirection='row'
682
+ justifyContent='space-between'
683
+ >
684
+ <box flexDirection='row' flexShrink={0}>
685
+ <text fg={props.theme.textMuted} wrapMode='none'>
686
+ {label()}
560
687
  </text>
561
- <For each={quotaBarSegments(used(), props.appearance, props.pacing)}>
688
+ <For
689
+ each={quotaBarSegments(
690
+ used(),
691
+ { ...props.appearance, barWidth: barCells() },
692
+ props.pacing,
693
+ )}
694
+ >
562
695
  {(segment) => (
563
- <text fg={toneColor(props.theme, segment.tone)}>
696
+ <text fg={toneColor(props.theme, segment.tone)} wrapMode='none'>
564
697
  {segment.text}
565
698
  </text>
566
699
  )}
567
700
  </For>
568
701
  <text
569
702
  fg={toneColor(props.theme, usageTone(used(), props.appearance))}
703
+ wrapMode='none'
570
704
  >
571
- {` ${props.text.slice(props.label.length + 1)}`}
705
+ {figure()}
572
706
  </text>
573
707
  </box>
574
- <Show when={props.resetText}>
575
- <text fg={props.theme.textMuted}>{props.resetText}</text>
708
+ <Show when={showReset()}>
709
+ <text fg={props.theme.textMuted} wrapMode='none' flexShrink={0}>
710
+ {props.resetText}
711
+ </text>
576
712
  </Show>
577
713
  </box>
578
- <Show when={paceLine()}>
579
- <box width='100%' flexDirection='row'>
580
- <text fg={props.theme.textMuted}>{''.padEnd(props.labelWidth)}</text>
581
- <text
582
- fg={toneColor(
583
- props.theme,
584
- props.pacing?.state === 'deficit' ? 'warn' : 'muted',
585
- )}
586
- >
587
- {paceLine()}
588
- </text>
589
- </box>
714
+ {/* Keyed, so a cut to a new width draws a new text node: on Bun 1.3
715
+ an existing node kept drawing the text it was created with. */}
716
+ <Show when={fittedPaceLine()} keyed>
717
+ {(line) => (
718
+ <box width='100%' flexDirection='row'>
719
+ <text fg={props.theme.textMuted}>
720
+ {''.padEnd(props.labelWidth)}
721
+ </text>
722
+ <text
723
+ fg={toneColor(
724
+ props.theme,
725
+ props.pacing?.state === 'deficit' ? 'warn' : 'muted',
726
+ )}
727
+ wrapMode='none'
728
+ >
729
+ {line}
730
+ </text>
731
+ </box>
732
+ )}
590
733
  </Show>
591
734
  </Show>
592
735
  )
@@ -672,14 +815,15 @@ function AccountBlock(props: {
672
815
  each={getAccountMetadataRows(
673
816
  props.resetCredits,
674
817
  props.quota?.spendControl,
818
+ accountCreditsText(props.quota),
819
+ props.active && accountServesOnCredits(props.quota, Date.now()),
675
820
  )}
676
821
  >
677
822
  {(row) => (
678
- <StatRow
823
+ <AccountMetaRow
679
824
  theme={props.theme}
680
825
  label={row.label}
681
826
  value={row.value}
682
- tone='text'
683
827
  />
684
828
  )}
685
829
  </For>
@@ -106,6 +106,25 @@ export function quotaWindowParts(quota, options) {
106
106
  const parts = windows.map(({ part }) => part);
107
107
  return age === undefined ? { windows: parts } : { windows: parts, age };
108
108
  }
109
+ /**
110
+ * The narrow label of each scoped window part, as `narrowTextParts` names
111
+ * them: model short names are worked out over every model-scoped window in
112
+ * the reading, so two models never share a short name.
113
+ */
114
+ function withShortNames(windows) {
115
+ const abbreviations = scopeAbbreviations(windows
116
+ .filter(({ part, scope }) => part.kind === 'window' && scope !== ALL_SCOPE)
117
+ .map(({ scope }) => scope));
118
+ for (const window of windows) {
119
+ window.part.short =
120
+ window.part.kind === 'budget'
121
+ ? 'cr'
122
+ : window.scope === ALL_SCOPE
123
+ ? window.part.name
124
+ : (abbreviations.get(window.scope) ?? window.scope);
125
+ }
126
+ return windows;
127
+ }
109
128
  /**
110
129
  * `quotaWindowParts` with each window's scope kept beside it. The narrow form
111
130
  * needs the scope to name and pick model-scoped windows; the public parts do
@@ -128,6 +147,7 @@ function scopedWindowParts(quota, options) {
128
147
  part: {
129
148
  kind: 'window',
130
149
  name: quotaWindowName(limit),
150
+ short: '',
131
151
  figure: percentLeft(limit.remainingPercent ?? 0),
132
152
  remainingPercent,
133
153
  checkedAt: limit.checkedAt,
@@ -149,6 +169,7 @@ function scopedWindowParts(quota, options) {
149
169
  part: {
150
170
  kind: 'budget',
151
171
  name: 'credits',
172
+ short: '',
152
173
  figure,
153
174
  ...(remainingPercent === undefined
154
175
  ? {}
@@ -158,6 +179,7 @@ function scopedWindowParts(quota, options) {
158
179
  },
159
180
  });
160
181
  }
182
+ withShortNames(windows);
161
183
  if (windows.length > 0) {
162
184
  const age = now - Math.min(...windows.map(({ part }) => part.checkedAt));
163
185
  if (age > (options.staleAfterMs ?? QUOTA_STALE_AFTER_MS)) {
@@ -298,20 +320,21 @@ function narrowTextParts(quota, options) {
298
320
  return grouped;
299
321
  const general = windows.filter(({ part, scope }) => part.kind === 'window' && scope === ALL_SCOPE);
300
322
  const scoped = windows.filter(({ part, scope }) => part.kind === 'window' && scope !== ALL_SCOPE);
301
- const abbreviations = scopeAbbreviations(scoped.map(({ scope }) => scope));
302
323
  // The items competing for the one slot after the account-wide windows, in
303
324
  // tie-break order: scoped windows in display order, then the budget. A
304
325
  // spent budget counts as 0% left; one without a percentage cannot compete.
326
+ // Their labels are the parts' short names, so the narrow line and a layout
327
+ // reading `short` always agree.
305
328
  const budget = windows.find(({ part }) => part.kind === 'budget');
306
329
  const budgetLeft = budget?.spent ? 0 : budget?.part.remainingPercent;
307
330
  const extras = [
308
- ...scoped.map(({ scope, part }) => ({
309
- label: abbreviations.get(scope) ?? scope,
331
+ ...scoped.map(({ part }) => ({
332
+ label: part.short,
310
333
  remaining: part.remainingPercent ?? 0,
311
334
  })),
312
- ...(budgetLeft === undefined
335
+ ...(budgetLeft === undefined || budget === undefined
313
336
  ? []
314
- : [{ label: 'cr', remaining: budgetLeft }]),
337
+ : [{ label: budget.part.short, remaining: budgetLeft }]),
315
338
  ];
316
339
  // Strictly lower wins, so on a tie the earlier item keeps the slot.
317
340
  const tightest = extras.reduce((best, item) => best === undefined || item.remaining < best.remaining ? item : best, undefined);
@@ -357,3 +380,25 @@ export function formatQuota(quota, options) {
357
380
  ? NO_LIMITS_REPORTED
358
381
  : NO_QUOTA_READING;
359
382
  }
383
+ /**
384
+ * Prepaid credits as one short value, worded as the official Codex client's
385
+ * status view words them: `Unlimited` for unlimited credits, `<n> credits`
386
+ * for a finite positive balance (rounded to a whole number), `Available` when
387
+ * the account has credits but no usable balance was reported, and undefined
388
+ * when the account has no credits (or `credits` is undefined), so a caller
389
+ * shows no credits row at all. Unlimited wins even when `hasCredits` is false.
390
+ * No other formatter adds this text; a caller that wants a credits row asks
391
+ * for it.
392
+ */
393
+ export function quotaCreditsText(credits) {
394
+ if (credits === undefined)
395
+ return undefined;
396
+ if (credits.unlimited)
397
+ return 'Unlimited';
398
+ if (!credits.hasCredits)
399
+ return undefined;
400
+ const { balance } = credits;
401
+ return typeof balance === 'number' && Number.isFinite(balance) && balance > 0
402
+ ? `${Math.round(balance)} credits`
403
+ : 'Available';
404
+ }