Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 130 additions & 1 deletion electron/ipc/claude-usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
fetchClaudeUsage,
parseAccessToken,
parseClaudeUsageResponse,
parseCreditUsage,
readKeychainCredentials,
type KeychainExec,
} from './claude-usage.js';
Expand Down Expand Up @@ -40,14 +41,142 @@ describe('parseClaudeUsageResponse', () => {
});
});

it('returns null when no window carries a percentage', () => {
it('includes creditUsage when extra_usage is present', () => {
const result = parseClaudeUsageResponse(
{
five_hour: { utilization: 10 },
extra_usage: {
is_enabled: true,
monthly_limit: 3000,
used_credits: 212,
utilization: 7.07,
currency: 'USD',
decimal_places: 2,
},
},
NOW,
);
expect(result).toEqual({
status: 'ok',
fiveHour: { usedPercent: 10, resetsAt: null },
sevenDay: null,
creditUsage: {
used: 2.12,
limit: 30,
currency: 'USD',
usedPercent: 7.07,
},
fetchedAt: NOW,
});
});

it('returns ok if only creditUsage is present without rate limit windows', () => {
const result = parseClaudeUsageResponse(
{
extra_usage: {
is_enabled: true,
monthly_limit: 1000,
used_credits: 500,
},
},
NOW,
);
expect(result).toEqual({
status: 'ok',
fiveHour: null,
sevenDay: null,
creditUsage: {
used: 5,
limit: 10,
currency: 'USD',
usedPercent: 50,
},
fetchedAt: NOW,
});
});

it('returns null when neither windows nor credit usage are present', () => {
expect(parseClaudeUsageResponse({ five_hour: { resets_at: 1 } })).toBeNull();
expect(parseClaudeUsageResponse({})).toBeNull();
expect(parseClaudeUsageResponse(null)).toBeNull();
expect(parseClaudeUsageResponse('nope')).toBeNull();
});
});

describe('parseCreditUsage', () => {
it('parses extra_usage with custom decimal places and currency', () => {
expect(
parseCreditUsage({
extra_usage: {
is_enabled: true,
monthly_limit: 5000,
used_credits: 1250,
currency: 'EUR',
decimal_places: 2,
},
}),
).toEqual({
used: 12.5,
limit: 50,
currency: 'EUR',
usedPercent: 25,
});
});

it('falls back to spend object when extra_usage is absent', () => {
expect(
parseCreditUsage({
spend: {
enabled: true,
used: { amount_minor: 350, currency: 'USD', exponent: 2 },
limit: { amount_minor: 1000, exponent: 2 },
percent: 35,
},
}),
).toEqual({
used: 3.5,
limit: 10,
currency: 'USD',
usedPercent: 35,
});
});

it('returns null when extra_usage is disabled and 0 credits used', () => {
expect(
parseCreditUsage({
extra_usage: {
is_enabled: false,
monthly_limit: 3000,
used_credits: 0,
},
}),
).toBeNull();
});

it('returns usage if credits have been used even if is_enabled is false', () => {
expect(
parseCreditUsage({
extra_usage: {
is_enabled: false,
monthly_limit: 3000,
used_credits: 150,
},
}),
).toEqual({
used: 1.5,
limit: 30,
currency: 'USD',
usedPercent: 5,
});
});

it('returns null for missing, null, or malformed data', () => {
expect(parseCreditUsage(null)).toBeNull();
expect(parseCreditUsage({})).toBeNull();
expect(parseCreditUsage('hello')).toBeNull();
});
});

describe('parseAccessToken', () => {
it('extracts the OAuth access token', () => {
expect(parseAccessToken('{"claudeAiOauth":{"accessToken":"tok"}}')).toBe('tok');
Expand Down
90 changes: 87 additions & 3 deletions electron/ipc/claude-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import path from 'path';
import crypto from 'crypto';
import { execFile } from 'child_process';
import { promisify } from 'util';
import type { UsageResult, UsageWindow } from './shared-types.js';
import type { CreditUsage, UsageResult, UsageWindow } from './shared-types.js';
import { warn as logWarn, errMessage } from '../log.js';
import { clampPercent, finite, parseResetsAt, requestUsage } from './usage-shared.js';

Expand Down Expand Up @@ -36,6 +36,83 @@ interface UsageWindowJson {
resets_at?: unknown;
}

interface RawSpendMinor {
amount_minor?: unknown;
currency?: unknown;
exponent?: unknown;
}

interface RawSpend {
used?: RawSpendMinor | null;
limit?: RawSpendMinor | null;
percent?: unknown;
enabled?: unknown;
}

interface RawExtraUsage {
is_enabled?: unknown;
monthly_limit?: unknown;
used_credits?: unknown;
utilization?: unknown;
currency?: unknown;
decimal_places?: unknown;
}

export function parseCreditUsage(body: unknown): CreditUsage | null {
if (typeof body !== 'object' || body === null) return null;
const raw = body as { extra_usage?: unknown; spend?: unknown };

if (typeof raw.extra_usage === 'object' && raw.extra_usage !== null) {
const eu = raw.extra_usage as RawExtraUsage;
const isEnabled = eu.is_enabled === true;
const decimals = typeof eu.decimal_places === 'number' ? eu.decimal_places : 2;
const divisor = 10 ** decimals;
const rawUsed = finite(eu.used_credits);
const rawLimit = finite(eu.monthly_limit);
const used = rawUsed !== null ? rawUsed / divisor : null;
const limit = rawLimit !== null ? rawLimit / divisor : null;
const currency = typeof eu.currency === 'string' && eu.currency ? eu.currency : 'USD';
const usedPercent = finite(eu.utilization);

if (used !== null && (isEnabled || used > 0)) {
const calcPercent = limit !== null && limit > 0 ? clampPercent((used / limit) * 100) : null;
return {
used,
limit,
currency,
usedPercent: usedPercent !== null ? clampPercent(usedPercent) : calcPercent,
};
}
}

if (typeof raw.spend === 'object' && raw.spend !== null) {
const sp = raw.spend as RawSpend;
const enabled = sp.enabled === true;
const usedMinor = finite(sp.used?.amount_minor);
const exp = typeof sp.used?.exponent === 'number' ? sp.used.exponent : 2;
const divisor = 10 ** exp;
const used = usedMinor !== null ? usedMinor / divisor : null;
const limitMinor = finite(sp.limit?.amount_minor);
const limitExp = typeof sp.limit?.exponent === 'number' ? sp.limit.exponent : exp;
const limit = limitMinor !== null ? limitMinor / 10 ** limitExp : null;
const currency =
typeof sp.used?.currency === 'string' && sp.used.currency ? sp.used.currency : 'USD';
const percent = finite(sp.percent);

if (used !== null && (enabled || used > 0)) {
const calcPercent = limit !== null && limit > 0 ? clampPercent((used / limit) * 100) : null;
return {
used,
limit,
currency,
usedPercent: percent !== null ? clampPercent(percent) : calcPercent,
};
}
}

return null;
}

function parseWindow(value: unknown): UsageWindow | null {
if (typeof value !== 'object' || value === null) return null;
const raw = value as UsageWindowJson;
Expand All @@ -50,8 +127,15 @@ export function parseClaudeUsageResponse(body: unknown, now = Date.now()): Usage
const raw = body as { five_hour?: unknown; seven_day?: unknown };
const fiveHour = parseWindow(raw.five_hour);
const sevenDay = parseWindow(raw.seven_day);
if (!fiveHour && !sevenDay) return null;
return { status: 'ok', fiveHour, sevenDay, fetchedAt: now };
const creditUsage = parseCreditUsage(body);
if (!fiveHour && !sevenDay && !creditUsage) return null;
return {
status: 'ok',
fiveHour,
sevenDay,
...(creditUsage ? { creditUsage } : {}),
fetchedAt: now,
};
}

/** Extracts the OAuth access token from a Claude credentials JSON document. */
Expand Down
12 changes: 12 additions & 0 deletions electron/ipc/shared-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,11 +288,23 @@ export interface UsageWindow {
resetsAt: number | null;
}

export interface CreditUsage {
/** Amount used in standard currency units (e.g. 2.12 for $2.12). */
used: number;
/** Spending limit in standard currency units, null if unlimited or not set. */
limit: number | null;
/** Currency code, e.g. "USD". */
currency: string;
/** Percent of limit used (0–100), null if limit is not set. */
usedPercent: number | null;
}

export type UsageResult =
| {
status: 'ok';
fiveHour: UsageWindow | null;
sevenDay: UsageWindow | null;
creditUsage?: CreditUsage | null;
fetchedAt: number;
}
/** No subscription login to read — the status bar hides itself. */
Expand Down
18 changes: 18 additions & 0 deletions src/components/UsageStatusBar.client.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,22 @@ describe('UsageStatusBar', () => {
container.querySelector<HTMLElement>('[role="status"]')?.click();
expect(mockRefreshUsage).toHaveBeenCalledWith('claude', { force: true });
});

it('shows credit spend inline for a login with no rate-limit windows', () => {
const saved = { ...usage.claude };
Object.assign(usage.claude, {
fiveHour: null,
sevenDay: null,
creditUsage: { used: 12.34, limit: 50, currency: 'USD', usedPercent: 24.68 },
});
try {
const container = mount();
const entry = container.querySelector<HTMLElement>('[role="status"]');
expect(entry?.textContent).toContain('Credits');
expect(entry?.textContent).toContain('$12.34 / $50.00');
expect(entry?.textContent).not.toContain('unavailable');
} finally {
Object.assign(usage.claude, saved);
}
});
});
55 changes: 54 additions & 1 deletion src/components/UsageStatusBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import { Portal } from 'solid-js/web';
import { store, refreshUsage, USAGE_PROVIDERS } from '../store/store';
import { theme } from '../lib/theme';
import { sf } from '../lib/fontScale';
import type { UsageProvider, UsageWindow } from '../ipc/types';
import type { CreditUsage, UsageProvider, UsageWindow } from '../ipc/types';
import type { UsageState } from '../store/types';
import {
USAGE_WARN_PERCENT,
formatCurrency,
formatFetchedAt,
formatReset,
hasUsageSnapshot,
Expand Down Expand Up @@ -61,6 +62,55 @@ function UsageMeter(props: { label: string; window: UsageWindow; width?: number
);
}

function CreditMeter(props: { credit: CreditUsage; width?: number }) {
const usedText = () => formatCurrency(props.credit.used, props.credit.currency);
const limitText = () =>
props.credit.limit !== null ? formatCurrency(props.credit.limit, props.credit.currency) : null;
const percent = () => (props.credit.limit !== null ? props.credit.usedPercent : null);
const warn = () => (props.credit.usedPercent ?? 0) >= USAGE_WARN_PERCENT;
const color = () => (warn() ? theme.warning : theme.accent);

return (
<span style={{ display: 'inline-flex', 'align-items': 'center', gap: '6px' }}>
<span style={{ color: theme.fgSubtle }}>Credits</span>
<Show when={percent()}>
{(pct) => (
<span
role="progressbar"
aria-label="Credit usage"
aria-valuenow={Math.round(pct())}
aria-valuemin={0}
aria-valuemax={100}
style={{
width: `${props.width ?? 80}px`,
height: '5px',
'border-radius': 'var(--radius-xs)',
background: theme.bgInput,
border: `1px solid ${theme.border}`,
overflow: 'hidden',
}}
>
<span
style={{
display: 'block',
height: '100%',
width: `${Math.min(100, Math.max(0, pct()))}%`,
background: color(),
}}
/>
</span>
)}
</Show>
<span style={{ color: warn() ? theme.warning : theme.fg, 'font-weight': '500' }}>
{limitText() ? `${usedText()} / ${limitText()}` : `${usedText()} used`}
</span>
<Show when={percent()}>
{(pct) => <span style={{ color: theme.fgSubtle }}>({Math.round(pct())}%)</span>}
</Show>
</span>
);
}

/** Glance-only detail card above a provider's bar entry: both windows, last refresh, any error. */
function UsagePopover(props: {
provider: UsageProvider;
Expand Down Expand Up @@ -115,6 +165,7 @@ function UsagePopover(props: {
<Show when={props.usage.sevenDay}>
{(w) => <UsageMeter label="7d" window={w()} width={120} />}
</Show>
<Show when={props.usage.creditUsage}>{(c) => <CreditMeter credit={c()} />}</Show>
<div
style={{
color: props.usage.status === 'error' ? theme.warning : theme.fgSubtle,
Expand Down Expand Up @@ -175,6 +226,8 @@ function ProviderUsage(props: { provider: UsageProvider }) {
{PROVIDER_LABELS[props.provider]}
</span>
<Show when={headline()}>{(h) => <UsageMeter label={h().label} window={h().window} />}</Show>
{/* Pay-per-use logins have no rate-limit windows; their spend is the headline. */}
<Show when={!headline() && usage().creditUsage}>{(c) => <CreditMeter credit={c()} />}</Show>
<Show when={!hasUsageSnapshot(usage())}>
<span>usage unavailable · {usage().error}</span>
</Show>
Expand Down
Loading