@astrosheep/pi-quota 0.12.1 → 0.13.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.
@@ -1,33 +1,36 @@
1
- export interface QuotaWindow {
1
+ export interface Balance {
2
2
  id: string;
3
3
  label: string;
4
- remainingPercent: number | null;
5
- resetAt: number | null;
6
- durationSeconds: number;
7
- scope?: string;
8
- amounts?: QuotaAllowance;
9
- }
10
- export interface AccountBalance {
11
4
  currency: string;
12
5
  remaining: number;
13
- unlimited?: boolean;
14
6
  }
15
- export interface QuotaAllowance {
16
- currency: string;
17
- limit: number;
7
+ export interface LimitAmounts {
8
+ unit: string;
9
+ limit: number | null;
18
10
  used: number;
19
11
  remaining: number;
20
12
  }
21
- export interface SpendSummary {
13
+ export interface UsageLimit {
14
+ id: string;
15
+ label: string;
16
+ scope?: string;
17
+ remainingPercent: number | null;
18
+ resetAt: number | null;
19
+ durationSeconds?: number;
20
+ amounts?: LimitAmounts;
21
+ unlimited?: true;
22
+ }
23
+ export interface Spending {
24
+ id: string;
25
+ label: string;
22
26
  currency: string;
23
- today?: number;
24
- lifetime?: number;
27
+ amount: number;
28
+ period: 'today' | 'lifetime';
25
29
  }
26
30
  export interface QuotaSnapshot {
27
- windows: readonly QuotaWindow[];
28
- balance?: AccountBalance;
29
- allowance?: QuotaAllowance;
30
- spend?: SpendSummary;
31
+ balances: Balance[];
32
+ limits: UsageLimit[];
33
+ spending: Spending[];
31
34
  fetchedAt: number;
32
35
  }
33
36
  export interface ProviderAuth {
@@ -62,7 +62,7 @@ function limitWindow(id, label, durationSeconds, limitRaw, usedRaw, resetAt) {
62
62
  }
63
63
  const remaining = Math.max(0, limit - used);
64
64
  return [{ id, label, durationSeconds, remainingPercent: percent(remaining / limit * 100), resetAt,
65
- amounts: { currency: 'USD', limit, used, remaining } }];
65
+ amounts: { unit: 'USD', limit, used, remaining } }];
66
66
  }
67
67
  // GET /api/v1/user/balance (optional; some accounts may lack it) merged with
68
68
  // GET /api/v1/auth/profile (required). The live response carries a generic
@@ -93,7 +93,7 @@ function spendingLimitWindows(balanceData) {
93
93
  const left = remaining ?? Math.max(0, limit - spent);
94
94
  return [{ id: `spending:${item.key}`, label: windowLabel(windowSeconds), durationSeconds: windowSeconds,
95
95
  remainingPercent: percent(left / limit * 100), resetAt,
96
- amounts: { currency: 'USD', limit, used: spent, remaining: left } }];
96
+ amounts: { unit: 'USD', limit, used: spent, remaining: left } }];
97
97
  });
98
98
  // Footer/detail convention is ascending duration (5h → 1w → 30d).
99
99
  return windows.sort((a, b) => a.durationSeconds - b.durationSeconds);
@@ -106,16 +106,15 @@ export function parseYesCodeAccount(balancePayload, profilePayload) {
106
106
  const paygo = optionalAmount(balanceData.pay_as_you_go_balance ?? profile.pay_as_you_go_balance);
107
107
  if (subscription === undefined && paygo === undefined)
108
108
  throw new QuotaError('schema');
109
- const weekStart = timestamp(profile.last_week_reset);
110
- const monthStart = timestamp(profile.last_month_reset);
111
- const windows = spendingLimitWindows(balanceData) ?? [
109
+ const limits = spendingLimitWindows(balanceData) ?? [
112
110
  ...limitWindow('daily', '1d', 86400, balanceData.daily_limit, balanceData.daily_spent, timestamp(balanceData.daily_resets_at ?? balanceData.daily_reset_at)),
113
- ...limitWindow('weekly', '1w', 604800, balanceData.weekly_limit ?? plan.weekly_limit, balanceData.weekly_spent_balance ?? balanceData.weekly_spent ?? profile.current_week_spend, timestamp(balanceData.weekly_resets_at ?? balanceData.weekly_reset_at)
114
- ?? (weekStart === null ? null : weekStart + 604800000)),
115
- ...limitWindow('monthly', '30d', 2592000, balanceData.monthly_spend_limit ?? plan.monthly_spend_limit, balanceData.monthly_spent_balance ?? balanceData.monthly_spent ?? profile.current_month_spend, timestamp(balanceData.monthly_resets_at ?? balanceData.monthly_reset_at)
116
- ?? (monthStart === null ? null : monthStart + 2592000000)),
111
+ ...limitWindow('weekly', '1w', 604800, balanceData.weekly_limit ?? plan.weekly_limit, balanceData.weekly_spent_balance ?? balanceData.weekly_spent ?? profile.current_week_spend, timestamp(balanceData.weekly_resets_at ?? balanceData.weekly_reset_at)),
112
+ ...limitWindow('monthly', '30d', 2592000, balanceData.monthly_spend_limit ?? plan.monthly_spend_limit, balanceData.monthly_spent_balance ?? balanceData.monthly_spent ?? profile.current_month_spend, timestamp(balanceData.monthly_resets_at ?? balanceData.monthly_reset_at)),
117
113
  ];
118
- return { windows, balance: { currency: 'USD', remaining: (subscription ?? 0) + (paygo ?? 0) } };
114
+ return { limits, balances: [
115
+ ...(subscription !== undefined ? [{ id: 'subscription', label: 'Subscription', currency: 'USD', remaining: subscription }] : []),
116
+ ...(paygo !== undefined ? [{ id: 'paygo', label: 'Pay-as-you-go', currency: 'USD', remaining: paygo }] : []),
117
+ ], spending: [] };
119
118
  }
120
119
  export function createYesCodeAdapter(provider, options) {
121
120
  const credentials = validateYesCodeOptions(options);
@@ -21,8 +21,10 @@ const customAdapter: QuotaAdapter = {
21
21
  if (remaining === null) throw new QuotaError('schema');
22
22
  return {
23
23
  fetchedAt: context.now(),
24
- windows: [{ id: 'daily', label: '1d', durationSeconds: 86400,
24
+ balances: [],
25
+ limits: [{ id: 'daily', label: '1d', durationSeconds: 86400,
25
26
  remainingPercent: percent(remaining), resetAt: timestamp(data.reset_at) }],
27
+ spending: [],
26
28
  };
27
29
  },
28
30
  };
@@ -10,10 +10,10 @@ const paint: Paint = process.env.NO_COLOR !== undefined ? (_tone, text) => text
10
10
  : (tone, text) => `\x1b[${colors[tone]}m${text}\x1b[0m`;
11
11
  function mock(short: number, weekly: number): QuotaState {
12
12
  return { kind: 'ready', provider: 'openai-codex', label: 'Codex', snapshot: {
13
- fetchedAt: now, windows: [
13
+ fetchedAt: now, balances: [], limits: [
14
14
  { id: '5h', label: '5h', durationSeconds: 18000, remainingPercent: short, resetAt: now + 8100000 },
15
15
  { id: '1w', label: '1w', durationSeconds: 604800, remainingPercent: weekly, resetAt: now + 442800000 },
16
- ],
16
+ ], spending: [],
17
17
  } };
18
18
  }
19
19
  console.log('MOCK DATA — no live queries\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/pi-quota",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -43,8 +43,12 @@
43
43
  "@earendil-works/pi-tui": "*"
44
44
  },
45
45
  "peerDependenciesMeta": {
46
- "@earendil-works/pi-coding-agent": { "optional": true },
47
- "@earendil-works/pi-tui": { "optional": true }
46
+ "@earendil-works/pi-coding-agent": {
47
+ "optional": true
48
+ },
49
+ "@earendil-works/pi-tui": {
50
+ "optional": true
51
+ }
48
52
  },
49
53
  "devDependencies": {
50
54
  "@types/node": "^22.0.0",
@@ -1,6 +1,5 @@
1
- import { horizontalBar, remainingTone, verticalBar } from './bar.ts';
2
- import type { AccountBalance, QuotaAllowance, SpendSummary } from '../query/types.ts';
3
- import type { Paint, Span } from './bar.ts';
1
+ import type { Balance, LimitAmounts } from '../query/types.ts';
2
+ import type { Span } from './bar.ts';
4
3
 
5
4
  export function money(amount: number, currency: string): string {
6
5
  const sign = amount < 0 ? '-' : '';
@@ -10,55 +9,18 @@ export function money(amount: number, currency: string): string {
10
9
  return `${sign}${symbol}${absolute.toFixed(2)}`;
11
10
  }
12
11
 
13
- export function remainingMoney(balance: AccountBalance): string {
14
- if (balance.unlimited === true) return balance.currency === 'CNY' ? '¥∞' : balance.currency === 'USD' ? '$∞' : `${balance.currency} ∞`;
15
- return money(balance.remaining, balance.currency);
12
+ export function quantity(amount: number, unit: string): string {
13
+ return /^[A-Z]{3}$/.test(unit) ? money(amount, unit) : `${amount.toLocaleString('en-US', { maximumFractionDigits: 8 })} ${unit}`;
16
14
  }
17
15
 
18
- export function compactMoney(amount: number, currency: string): string {
19
- return money(amount, currency);
16
+ export function amountSummary(amounts: LimitAmounts): string {
17
+ return `${quantity(amounts.remaining, amounts.unit)}/${amounts.limit === null ? '?' : quantity(amounts.limit, amounts.unit)}`
18
+ + ` · ${quantity(amounts.used, amounts.unit)} used`;
20
19
  }
21
20
 
22
- export function compactRemainingMoney(balance: AccountBalance): string {
23
- return remainingMoney(balance);
24
- }
25
-
26
- export function balanceSpans(balance: AccountBalance): Span[] {
27
- return [
28
- { text: 'Bal ', tone: 'dim' },
29
- { text: compactRemainingMoney(balance), tone: !balance.unlimited && balance.remaining <= 0 ? 'error' : 'text' },
30
- ];
31
- }
32
-
33
- export function balanceLines(balance: AccountBalance, paint: Paint): string[] {
34
- const remaining = remainingMoney(balance);
35
- return [paint('dim', 'Balance ') + paint(!balance.unlimited && balance.remaining <= 0 ? 'error' : 'text', remaining)];
36
- }
37
-
38
- export function allowancePercent(allowance: QuotaAllowance): number {
39
- return Math.max(0, Math.min(100, allowance.remaining / allowance.limit * 100));
40
- }
41
-
42
- export function allowanceSpans(allowance: QuotaAllowance): Span[] {
43
- const remaining = allowancePercent(allowance);
44
- return [
45
- { text: 'Quota ', tone: 'dim' },
46
- { text: `${verticalBar(remaining)} ${compactMoney(allowance.remaining, allowance.currency)}`, tone: remainingTone(remaining) },
47
- ];
48
- }
49
-
50
- export function allowanceLines(allowance: QuotaAllowance, paint: Paint, barWidth: number): string[] {
51
- const remaining = allowancePercent(allowance);
21
+ export function balanceSpans(balance: Balance, multiple = false): Span[] {
52
22
  return [
53
- paint(remainingTone(remaining), horizontalBar(remaining, barWidth))
54
- + paint('dim', ` ${money(allowance.remaining, allowance.currency)} left`),
55
- paint('dim', `Used ${money(allowance.used, allowance.currency)}`),
56
- paint('dim', `Limit ${money(allowance.limit, allowance.currency)}`),
23
+ { text: `${!multiple && balance.label === 'Balance' ? 'Bal' : balance.label} `, tone: 'dim' },
24
+ { text: money(balance.remaining, balance.currency), tone: balance.remaining <= 0 ? 'error' : 'text' },
57
25
  ];
58
26
  }
59
-
60
- export function spendLines(spend: SpendSummary, paint: Paint): string[] {
61
- const lines: string[] = [];
62
- if (spend.today !== undefined) lines.push(paint('dim', `Today ${money(spend.today, spend.currency)}`));
63
- return lines;
64
- }
package/src/bar/card.ts CHANGED
@@ -1,21 +1,19 @@
1
1
  import { Text, visibleWidth } from '@earendil-works/pi-tui';
2
2
  import type { Component } from '@earendil-works/pi-tui';
3
- import { duration, formatPercent, horizontalBar, remainingTone } from './bar.ts';
3
+ import { formatPercent, horizontalBar, remainingTone } from './bar.ts';
4
4
  import type { Paint, Tone } from './bar.ts';
5
- import { money, remainingMoney } from './balance.ts';
6
- import { renderUsage } from './quota.ts';
5
+ import { amountSummary, money } from './balance.ts';
6
+ import { limitLabel, limitRemaining, limitReset, renderUsage } from './quota.ts';
7
7
  import type { QuotaErrorCode, QuotaState } from '../query/types.ts';
8
8
 
9
- export const USAGE_ENTRY = 'quota-bar:usage:v1';
9
+ export const USAGE_ENTRY = 'quota-bar:usage:v2';
10
10
 
11
11
  export type UsageCard =
12
12
  | { version: 1; capturedAt: number; state: Exclude<QuotaState, { kind: 'loading' }> }
13
- // currentProvider is optional so cards saved before the marker existed still render.
14
13
  | { version: 2; capturedAt: number; states: Exclude<QuotaState, { kind: 'loading' }>[]; currentProvider?: string };
15
14
 
16
15
  export function captureUsage(state: QuotaState, now = Date.now()): UsageCard | undefined {
17
16
  if (state.kind === 'loading') return undefined;
18
- // Preserve v1 rendering for cards already saved in sessions.
19
17
  return { version: 1, capturedAt: now, state: structuredClone(state) };
20
18
  }
21
19
 
@@ -61,45 +59,36 @@ function gridRows(states: readonly Exclude<QuotaState, { kind: 'loading' }>[], n
61
59
  const mark = (entry: { provider: string; label: string }) =>
62
60
  currentProvider !== undefined && entry.provider === currentProvider ? `★ ${entry.label}` : entry.label;
63
61
  if (state.kind === 'ready') {
64
- const windows = state.snapshot.windows;
65
- if (windows.length > 0) {
66
- windows.forEach((window, index) => {
67
- const expired = window.resetAt !== null && window.resetAt <= now;
68
- const remaining = expired ? null : window.remainingPercent;
69
- rows.push({
70
- group, kind: 'quota',
71
- provider: index === 0 ? mark(state) : '',
72
- metric: `${window.scope ? `${window.scope}/` : ''}${window.label}`,
73
- value: `${horizontalBar(remaining, barWidth)} ${padStartVisible(formatPercent(remaining), 4)}`,
74
- secondary: `↺ ${window.resetAt === null ? '?' : duration(window.resetAt - now)}`,
75
- valueTone: remainingTone(remaining), metricTone: 'dim', secondaryTone: 'dim',
76
- });
77
- });
78
- return;
79
- }
80
- if (state.snapshot.balance) {
81
- const balance = state.snapshot.balance;
82
- rows.push({
83
- group, kind: 'balance', provider: mark(state), metric: '',
84
- value: remainingMoney(balance),
85
- secondary: state.snapshot.spend?.today !== undefined
86
- ? `today ${money(state.snapshot.spend.today, state.snapshot.spend.currency)}` : '',
87
- valueTone: !balance.unlimited && balance.remaining <= 0 ? 'error' : 'text',
88
- metricTone: 'dim', secondaryTone: 'dim',
89
- });
90
- return;
91
- }
92
- if (state.snapshot.allowance) {
93
- const allowance = state.snapshot.allowance;
94
- const remaining = Math.max(0, Math.min(100, allowance.remaining / allowance.limit * 100));
62
+ const firstRow = rows.length;
63
+ state.snapshot.limits.forEach(limit => {
64
+ const remaining = limitRemaining(limit, now);
65
+ const reset = limitReset(limit, now);
95
66
  rows.push({
96
- group, kind: 'balance', provider: mark(state), metric: '',
97
- value: `${horizontalBar(remaining, barWidth)} ${formatPercent(remaining)}`,
98
- secondary: `${money(allowance.used, allowance.currency)} used`,
99
- valueTone: remainingTone(remaining), metricTone: 'dim', secondaryTone: 'dim',
67
+ group, kind: 'quota', provider: '', metric: limitLabel(limit),
68
+ value: limit.unlimited ? '∞' : `${horizontalBar(remaining, barWidth)} ${padStartVisible(formatPercent(remaining), 4)}`,
69
+ secondary: [reset, limit.amounts ? amountSummary(limit.amounts) : ''].filter(Boolean).join(' · '),
70
+ valueTone: limit.unlimited ? 'text' : remainingTone(remaining), metricTone: 'dim', secondaryTone: 'dim',
100
71
  });
101
- return;
72
+ });
73
+ const balances = state.snapshot.balances;
74
+ balances.forEach(balance => rows.push({
75
+ group, kind: 'balance', provider: '',
76
+ metric: balances.length === 1 && balance.label === 'Balance' ? '' : balance.label,
77
+ value: money(balance.remaining, balance.currency), secondary: '',
78
+ valueTone: balance.remaining <= 0 ? 'error' : 'text', metricTone: 'dim', secondaryTone: 'dim',
79
+ }));
80
+ const today = state.snapshot.spending.filter(entry => entry.period === 'today');
81
+ // Keep the compact single-wallet + Today layout, but never drop other entries.
82
+ if (balances.length === 1 && today.length === 1) {
83
+ rows[rows.length - 1].secondary = `today ${money(today[0].amount, today[0].currency)}`;
84
+ } else {
85
+ today.forEach(entry => rows.push({
86
+ group, kind: 'balance', provider: '', metric: `Today${today.length > 1 ? ` ${entry.label}` : ''}`,
87
+ value: money(entry.amount, entry.currency), secondary: '',
88
+ valueTone: 'dim', metricTone: 'dim', secondaryTone: 'dim',
89
+ }));
102
90
  }
91
+ if (rows.length > firstRow) rows[firstRow].provider = mark(state);
103
92
  }
104
93
  if (state.kind === 'error') {
105
94
  rows.push({ group, kind: 'error', provider: mark(state), metric: '', value: errorText[state.code], secondary: '',
@@ -121,7 +110,7 @@ export function aggregateUsageLines(
121
110
  const quotaRows = rows.filter(row => row.kind === 'quota');
122
111
  const otherRows = rows.filter(row => row.kind !== 'quota');
123
112
  const providerWidth = Math.max(...quotaRows.map(row => visibleWidth(row.provider)), ...otherRows.map(row => visibleWidth(row.provider)));
124
- const metricWidth = Math.max(0, ...quotaRows.map(row => visibleWidth(row.metric)));
113
+ const metricWidth = Math.max(0, ...rows.map(row => visibleWidth(row.metric)));
125
114
  const quotaValueWidth = Math.max(0, ...quotaRows.map(row => visibleWidth(row.value)));
126
115
  const otherValueWidth = Math.max(0, ...otherRows.map(row => visibleWidth(row.value)));
127
116
  const result: string[] = [];
@@ -130,7 +119,7 @@ export function aggregateUsageLines(
130
119
  if (previousGroup !== undefined && row.group !== previousGroup && (row.kind === 'quota' || rows.some(other => other.group === previousGroup && other.kind === 'quota'))) result.push('');
131
120
  previousGroup = row.group;
132
121
  const provider = padEndVisible(row.provider, providerWidth);
133
- const metric = row.kind === 'quota' ? padEndVisible(row.metric, metricWidth) : '';
122
+ const metric = row.kind === 'quota' || row.metric ? padEndVisible(row.metric, metricWidth) : '';
134
123
  const value = padEndVisible(row.value, row.kind === 'quota' ? quotaValueWidth : otherValueWidth);
135
124
  const line = paint('text', provider)
136
125
  + (metric ? paint(row.metricTone, ` ${metric}`) : '')
@@ -147,7 +136,7 @@ export function usageLines(card: UsageCard, paint?: Paint, barWidth = 12): strin
147
136
  }
148
137
 
149
138
  // Narrow-client layout (RPC notify bubbles, phones): provider name on its own
150
- // header line, windows indented below. No wide grid columns, so monospace code
139
+ // header line, limits and balances indented below. No wide grid columns, so monospace code
151
140
  // blocks never wrap. Paseo's fence blocks have no horizontal scroll: RN Text
152
141
  // wraps long lines and a wrapped grid is worse than no grid.
153
142
  export function aggregateCompactLines(
@@ -159,13 +148,13 @@ export function aggregateCompactLines(
159
148
  ): string[] {
160
149
  const rows = gridRows(states, now, barWidth, currentProvider);
161
150
  if (rows.length === 0) return ['No quota data available. Run /usage --all for details.'];
162
- const metricWidth = Math.max(0, ...rows.filter(row => row.kind === 'quota').map(row => visibleWidth(row.metric)));
151
+ const metricWidth = Math.max(0, ...rows.map(row => visibleWidth(row.metric)));
163
152
  const lines: string[] = [];
164
153
  let previousGroup: number | undefined;
165
154
  for (const row of rows) {
166
155
  if (previousGroup !== undefined && row.group !== previousGroup) lines.push('');
167
156
  previousGroup = row.group;
168
- if (row.kind === 'quota') {
157
+ if (row.kind === 'quota' || row.metric || !row.provider) {
169
158
  if (row.provider) lines.push(paint('text', row.provider));
170
159
  const metric = padEndVisible(row.metric, metricWidth);
171
160
  const line = ` ${paint(row.metricTone, metric)} ${paint(row.valueTone, row.value)}`
package/src/bar/quota.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { visibleWidth } from '@earendil-works/pi-tui';
2
2
  import { duration, formatPercent, horizontalBar, plain, remainingTone, renderBar, verticalBar } from './bar.ts';
3
3
  import type { BarElement, Paint, Span } from './bar.ts';
4
- import { allowanceLines, allowanceSpans, balanceLines, balanceSpans, money, spendLines } from './balance.ts';
5
- import type { QuotaErrorCode, QuotaState, QuotaWindow } from '../query/types.ts';
4
+ import { amountSummary, balanceSpans, money, quantity } from './balance.ts';
5
+ import type { QuotaErrorCode, QuotaState, UsageLimit } from '../query/types.ts';
6
6
  import type { FooterWindows } from '../query/settings.ts';
7
7
 
8
8
  const errorText: Record<QuotaErrorCode, string> = {
@@ -13,19 +13,34 @@ const errorText: Record<QuotaErrorCode, string> = {
13
13
  'rate-limit': 'Rate limited', http: 'Service unavailable', schema: 'Unrecognized quota response',
14
14
  };
15
15
 
16
- function windowSpans(window: QuotaWindow, now: number): Span[] {
17
- const expired = window.resetAt !== null && window.resetAt <= now;
16
+ export function limitLabel(limit: UsageLimit): string {
17
+ return `${limit.scope ? `${limit.scope}/` : ''}${limit.label}`;
18
+ }
19
+
20
+ export function limitRemaining(limit: UsageLimit, now: number): number | null {
18
21
  // Reaching the reset deadline is not proof the provider granted a fresh quota.
19
- const remaining = expired ? null : window.remainingPercent;
20
- const glyph = verticalBar(remaining);
22
+ return limit.resetAt !== null && limit.resetAt <= now ? null : limit.remainingPercent;
23
+ }
24
+
25
+ export function limitReset(limit: UsageLimit, now: number): string {
26
+ // No schedule is inferred from duration alone. No duration/reset means unknown.
27
+ return limit.resetAt !== null ? `↺ ${duration(limit.resetAt - now)}`
28
+ : limit.durationSeconds !== undefined ? '↺ ?' : '';
29
+ }
30
+
31
+ function limitSpans(limit: UsageLimit, now: number): Span[] {
32
+ const remaining = limitRemaining(limit, now);
33
+ const value = limit.unlimited ? '∞' : `${verticalBar(remaining)} ${limit.amounts && limit.durationSeconds === undefined
34
+ ? quantity(limit.amounts.remaining, limit.amounts.unit) : formatPercent(remaining)}`;
35
+ const reset = limitReset(limit, now);
21
36
  return [
22
- { text: `${window.scope ? `${window.scope}/` : ''}${window.label} `, tone: 'dim' },
23
- { text: `${glyph} ${formatPercent(remaining)}`, tone: remainingTone(remaining) },
24
- { text: ` ↺ ${window.resetAt === null ? '?' : duration(window.resetAt - now)}`, tone: 'dim' },
37
+ { text: `${limitLabel(limit)} `, tone: 'dim' },
38
+ { text: value, tone: limit.unlimited ? 'text' : remainingTone(remaining) },
39
+ ...(reset ? [{ text: ` ${reset}`, tone: 'dim' as const }] : []),
25
40
  ];
26
41
  }
27
42
 
28
- // Footer window cap: a positive count, or 'all' for every shared window.
43
+ // Retain the existing setting name; it now caps displayed limits, not wallets.
29
44
  export type { FooterWindows };
30
45
 
31
46
  export function quotaElement(state: QuotaState, now = Date.now(), maxWindows: FooterWindows = 2): BarElement {
@@ -33,17 +48,18 @@ export function quotaElement(state: QuotaState, now = Date.now(), maxWindows: Fo
33
48
  if (state.kind === 'hidden') return element;
34
49
  if (state.kind === 'loading') return { ...element, spans: [{ text: 'Quota …', tone: 'dim' }] };
35
50
  if (state.kind === 'error') return { ...element, spans: [{ text: `Quota ! ${errorText[state.code]}`, tone: 'warning' }] };
36
- const windows = state.snapshot.windows;
37
- const shared = windows.filter(window => !window.scope);
38
- const eligible = shared.length ? shared : windows;
51
+ const { balances, limits } = state.snapshot;
52
+ const shared = limits.filter(limit => !limit.scope);
53
+ const eligible = shared.length ? shared : limits;
39
54
  const shown = maxWindows === 'all' ? eligible : eligible.slice(0, maxWindows);
40
- const spans: Span[] = state.snapshot.balance ? balanceSpans(state.snapshot.balance)
41
- : state.snapshot.allowance ? allowanceSpans(state.snapshot.allowance) : [];
42
- for (const window of shown) {
55
+ const spans: Span[] = [];
56
+ const append = (parts: Span[]) => {
43
57
  if (spans.length) spans.push({ text: ' · ', tone: 'dim' });
44
- spans.push(...windowSpans(window, now));
45
- }
46
- if (windows.length > shown.length) spans.push({ text: ` +${windows.length - shown.length}`, tone: 'dim' });
58
+ spans.push(...parts);
59
+ };
60
+ balances.forEach(balance => append(balanceSpans(balance, balances.length > 1)));
61
+ shown.forEach(limit => append(limitSpans(limit, now)));
62
+ if (limits.length > shown.length) spans.push({ text: ` +${limits.length - shown.length}`, tone: 'dim' });
47
63
  return { ...element, spans };
48
64
  }
49
65
 
@@ -57,38 +73,27 @@ export function renderUsage(state: QuotaState, paint: Paint = plain, now = Date.
57
73
  const indent = (line: string) => line ? ` ${line}` : line;
58
74
  if (state.kind === 'loading') return [title, '', indent('Loading…')];
59
75
  if (state.kind === 'error') return [title, '', indent(errorText[state.code])];
60
- const rows = state.snapshot.windows.map(window => {
61
- const remaining = window.resetAt !== null && window.resetAt <= now ? null : window.remainingPercent;
62
- return {
63
- label: `${window.scope ? `${window.scope}/` : ''}${window.label}`,
64
- remaining,
65
- value: formatPercent(remaining),
66
- reset: window.resetAt === null ? '?' : duration(window.resetAt - now),
67
- amounts: window.amounts,
68
- };
69
- });
70
- const labelWidth = Math.max(0, ...rows.map(row => visibleWidth(row.label)));
71
- const valueWidth = Math.max(4, ...rows.map(row => visibleWidth(row.value)));
72
- const lines = [title, '', ...rows.map(row => {
73
- const tone = remainingTone(row.remaining);
74
- const value = ' '.repeat(valueWidth - visibleWidth(row.value)) + row.value;
75
- return indent(paint('dim', `${row.label}${' '.repeat(labelWidth - visibleWidth(row.label))} `)
76
- + paint(tone, `${horizontalBar(row.remaining, barWidth)} ${value}`)
77
- + paint('dim', ` ↺ ${row.reset}${row.amounts
78
- ? ` · ${money(row.amounts.remaining, row.amounts.currency)}/${money(row.amounts.limit, row.amounts.currency)}`
79
- : ''}`));
76
+ const { limits, balances, spending } = state.snapshot;
77
+ const labelWidth = Math.max(0, ...limits.map(limit => visibleWidth(limitLabel(limit))));
78
+ const valueWidth = Math.max(4, ...limits.map(limit => visibleWidth(formatPercent(limitRemaining(limit, now)))));
79
+ const lines = [title, '', ...limits.map(limit => {
80
+ const remaining = limitRemaining(limit, now);
81
+ const value = formatPercent(remaining);
82
+ const reset = limitReset(limit, now);
83
+ return indent(paint('dim', `${limitLabel(limit)}${' '.repeat(labelWidth - visibleWidth(limitLabel(limit)))} `)
84
+ + paint(limit.unlimited ? 'text' : remainingTone(remaining), limit.unlimited ? '∞'
85
+ : `${horizontalBar(remaining, barWidth)} ${' '.repeat(valueWidth - visibleWidth(value))}${value}`)
86
+ + paint('dim', `${reset ? ` ${reset}` : ''}${limit.amounts ? ` · ${amountSummary(limit.amounts)}` : ''}`));
80
87
  })];
81
- if (state.snapshot.allowance) {
82
- if (rows.length) lines.push('');
83
- lines.push(...allowanceLines(state.snapshot.allowance, paint, barWidth).map(indent));
84
- }
85
- if (state.snapshot.balance) {
86
- if (rows.length || state.snapshot.allowance) lines.push('');
87
- lines.push(...balanceLines(state.snapshot.balance, paint).map(indent));
88
+ if (balances.length) {
89
+ if (limits.length) lines.push('');
90
+ balances.forEach(balance => lines.push(indent(paint('dim', `${balance.label} `)
91
+ + paint(balance.remaining <= 0 ? 'error' : 'text', money(balance.remaining, balance.currency)))));
88
92
  }
89
- if (state.snapshot.spend?.today !== undefined) {
90
- if (rows.length || state.snapshot.allowance || state.snapshot.balance) lines.push('');
91
- lines.push(...spendLines(state.snapshot.spend, paint).map(indent));
93
+ const today = spending.filter(entry => entry.period === 'today');
94
+ if (today.length) {
95
+ if (limits.length || balances.length) lines.push('');
96
+ today.forEach(entry => lines.push(indent(paint('dim', `Today${today.length > 1 ? ` ${entry.label}` : ''} ${money(entry.amount, entry.currency)}`))));
92
97
  }
93
98
  return lines;
94
99
  }
package/src/extension.ts CHANGED
@@ -8,7 +8,7 @@ import { createJsonClient, createPostJsonClient } from './query/http.ts';
8
8
  import { composeQuotaRegistry } from './query/settings.ts';
9
9
  import type { ProviderAuth, QuotaAdapter, QuotaState } from './query/types.ts';
10
10
 
11
- export const STATUS_EVENT = 'quota-bar:state:v1';
11
+ export const STATUS_EVENT = 'quota-bar:state:v2';
12
12
  const STATUS_KEY = 'quota-bar';
13
13
  const USAGE_WIDGET = 'quota-bar:usage';
14
14
  const REFRESHING_TEXT = 'Refreshing…';
@@ -21,7 +21,7 @@ export interface QuotaExtensionOptions {
21
21
  providers?: readonly string[]; // Extra configured quota providers to include in /usage
22
22
  aliases?: Record<string, string>; // alias provider → target provider; shares the target's account quota
23
23
  footer?: boolean; // false: query + /usage + structured events only
24
- footerWindows?: FooterWindows; // footer window cap; default 2, 'all' shows every window
24
+ footerWindows?: FooterWindows; // footer limit cap; default 2, 'all' shows every limit
25
25
  intervalMs?: number;
26
26
  timeoutMs?: number;
27
27
  }
@@ -222,7 +222,7 @@ export function createQuotaExtension(options: QuotaExtensionOptions = {}) {
222
222
  return provider === currentProvider || provider === canonicalProvider ? 0 : 1;
223
223
  };
224
224
  const rank = (state: QuotaState) => state.kind === 'error' ? 3
225
- : state.kind === 'ready' && state.snapshot.windows.length > 0 ? 1
225
+ : state.kind === 'ready' && state.snapshot.limits.length > 0 ? 1
226
226
  : 2;
227
227
  return pinned(a) - pinned(b) || rank(a.state) - rank(b.state) || a.index - b.index;
228
228
  })
@@ -1,7 +1,7 @@
1
1
  import { bearer, header, officialAuth } from './http.ts';
2
2
  import { numeric, object, percent, safeLabel, timestamp, windowLabel } from './parse.ts';
3
3
  import { QuotaError } from './types.ts';
4
- import type { QuotaAdapter, QuotaWindow } from './types.ts';
4
+ import type { QuotaAdapter, UsageLimit } from './types.ts';
5
5
 
6
6
  // Decode only to obtain routing metadata from the same resolved token.
7
7
  // This does not verify a JWT; the official server verifies authentication.
@@ -13,9 +13,9 @@ export function codexAccountId(token: string): string | undefined {
13
13
  } catch { return undefined; }
14
14
  }
15
15
 
16
- export function parseCodex(payload: unknown, now: number): QuotaWindow[] {
16
+ export function parseCodex(payload: unknown, now: number): UsageLimit[] {
17
17
  const data = object(payload);
18
- const windows: QuotaWindow[] = [];
18
+ const windows: UsageLimit[] = [];
19
19
  const addDomain = (raw: unknown, domain: string, scope?: string) => {
20
20
  const rate = object(raw);
21
21
  for (const [key, fallback] of [['primary_window', 18000], ['secondary_window', 604800]] as const) {
@@ -60,6 +60,6 @@ export const codexAdapter: QuotaAdapter = {
60
60
  Origin: 'https://chatgpt.com', Referer: 'https://chatgpt.com/',
61
61
  }, context.signal);
62
62
  const now = context.now();
63
- return { windows: parseCodex(data, now), fetchedAt: now };
63
+ return { balances: [], limits: parseCodex(data, now), spending: [], fetchedAt: now };
64
64
  },
65
65
  };
@@ -1,25 +1,24 @@
1
1
  import { bearer, officialAuth } from './http.ts';
2
2
  import { numeric, object } from './parse.ts';
3
3
  import { QuotaError } from './types.ts';
4
- import type { AccountBalance, QuotaAdapter } from './types.ts';
4
+ import type { Balance, QuotaAdapter } from './types.ts';
5
5
 
6
6
  export const DEEPSEEK_ORIGIN = 'https://api.deepseek.com';
7
7
 
8
8
  // https://api-docs.deepseek.com/api/get-user-balance
9
9
  // GET /user/balance (also mounted under /v1/). The sk- key works directly.
10
10
  // Amounts arrive as strings in yuan; balance_infos may list several currencies.
11
- export function parseDeepSeekBalance(payload: unknown): AccountBalance {
11
+ export function parseDeepSeekBalance(payload: unknown): Balance[] {
12
12
  const body = object(payload);
13
13
  if (!Array.isArray(body.balance_infos) || body.balance_infos.length === 0) throw new QuotaError('schema');
14
- for (const entry of body.balance_infos) {
14
+ return body.balance_infos.map(entry => {
15
15
  const info = object(entry);
16
16
  const remaining = numeric(info.total_balance);
17
17
  if (typeof info.currency !== 'string' || !/^[A-Z]{3}$/.test(info.currency)
18
- || remaining === null || remaining < 0) continue;
19
- // The endpoint exposes no usage total; only return the actual balance.
20
- return { currency: info.currency, remaining };
21
- }
22
- throw new QuotaError('schema');
18
+ || remaining === null) throw new QuotaError('schema');
19
+ // Preserve each currency; the endpoint exposes no usage total.
20
+ return { id: info.currency, label: 'Balance', currency: info.currency, remaining };
21
+ });
23
22
  }
24
23
 
25
24
  export function createDeepSeekAdapter(provider: string): QuotaAdapter {
@@ -29,7 +28,7 @@ export function createDeepSeekAdapter(provider: string): QuotaAdapter {
29
28
  const auth = await officialAuth(context, DEEPSEEK_ORIGIN);
30
29
  const payload = await context.getJson(`${DEEPSEEK_ORIGIN}/user/balance`,
31
30
  { Authorization: `Bearer ${bearer(auth)}` }, context.signal);
32
- return { windows: [], balance: parseDeepSeekBalance(payload), fetchedAt: context.now() };
31
+ return { balances: parseDeepSeekBalance(payload), limits: [], spending: [], fetchedAt: context.now() };
33
32
  },
34
33
  };
35
34
  }