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
4 changes: 2 additions & 2 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ bun run example
- **Input Speed** / **Output Speed** / **Total Speed** - Show session-average token throughput with an optional per-widget rolling window (`0-120` seconds; `0` = full-session average).
- **Context Length** / **Context Window** / **Context %** / **Context % (usable)** / **Context Bar** - Show current context length, total context window size, used/remaining percentage, usable-window percentage, or a progress bar.
- **Compaction Counter** - Show how many context compactions have been detected in the current session. It can render as icon plus number, text plus number, or number-only, and can hide while the count is zero.
- **Session Usage** / **Weekly Usage** / **Block Timer** / **Block Reset Timer** / **Weekly Reset Timer** - Show usage percentages plus current block/reset timing. Session and weekly usage bars can show a time cursor; reset timers can show remaining time, progress, or exact reset date/time with timezone and locale controls.
- **Session Usage** / **Weekly Usage** / **Weekly Sonnet Usage** / **Weekly Opus Usage** / **Block Timer** / **Block Reset Timer** / **Weekly Reset Timer** - Show usage percentages plus current block/reset timing. The all-models weekly bar covers `seven_day` from the usage API; the per-model variants surface the `seven_day_sonnet` and `seven_day_opus` buckets that Claude Code's own `/usage` panel shows. Session and weekly usage bars can show a time cursor; reset timers can show remaining time, progress, or exact reset date/time with timezone and locale controls.

### Environment, Layout & Custom

Expand Down Expand Up @@ -158,7 +158,7 @@ Widget-specific shortcuts:
- **Git Origin Owner/Repo**: `o` show only the owner when the repo is a fork
- **Git Is Fork**: `h` hide when the repo is not a fork
- **Context % widgets**: `u` toggle used vs remaining display, `p` cycle percentage/short bar/short bar only
- **Session Usage / Weekly Usage**: `p` cycle percentage/full bar/medium bar/short bar/short bar only, `v` invert fill in progress mode, `t` toggle the time cursor in bar modes
- **Session Usage / Weekly Usage / Weekly Sonnet Usage / Weekly Opus Usage**: `p` cycle percentage/full bar/medium bar/short bar/short bar only, `v` invert fill in progress mode, `t` toggle the time cursor in bar modes
- **Block Timer**: `p` cycle time/full bar/short bar, `s` toggle compact time, `v` invert fill in progress mode
- **Block Reset Timer**: `p` cycle time/full bar/short bar, `s` toggle compact time/date, `t` toggle exact reset date/time, `h` toggle 12/24-hour display in date mode, `z` edit timezone in date mode, `l` edit locale in date mode, `v` invert fill in progress mode
- **Weekly Reset Timer**: `p` cycle time/full bar/short bar, `s` toggle compact time/date, `t` toggle exact reset date/time, `h` toggle hours-only in time mode or 12/24-hour display in date mode, `z` edit timezone in date mode, `l` edit locale in date mode, `v` invert fill in progress mode
Expand Down
4 changes: 4 additions & 0 deletions src/types/RenderContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ export interface RenderUsageData {
sessionResetAt?: string;
weeklyUsage?: number;
weeklyResetAt?: string;
weeklySonnetUsage?: number;
weeklySonnetResetAt?: string;
weeklyOpusUsage?: number;
weeklyOpusResetAt?: string;
extraUsageEnabled?: boolean;
extraUsageLimit?: number;
extraUsageUsed?: number;
Expand Down
4 changes: 3 additions & 1 deletion src/types/StatusJSON.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ export const StatusJSONSchema = z.looseObject({
}).nullable().optional(),
rate_limits: z.object({
five_hour: RateLimitPeriodSchema.optional(),
seven_day: RateLimitPeriodSchema.optional()
seven_day: RateLimitPeriodSchema.optional(),
seven_day_sonnet: RateLimitPeriodSchema.nullable().optional(),
seven_day_opus: RateLimitPeriodSchema.nullable().optional()
}).nullable().optional()
});

Expand Down
100 changes: 100 additions & 0 deletions src/utils/__tests__/usage-prefetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,70 @@ describe('usage prefetch', () => {
expect(mockFetchUsageData.mock.calls.length).toBe(1);
});

it('uses per-model rate_limits buckets when a per-model widget is present', async () => {
mockFetchUsageData.mockResolvedValue({ sessionUsage: 99 });

const lines = makeLines(
[{ id: '1', type: 'weekly-sonnet-usage' }, { id: '2', type: 'weekly-opus-usage' }]
);

const usageData = await prefetchUsageDataIfNeeded(lines, {
rate_limits: {
five_hour: { used_percentage: 42, resets_at: 1774020000 },
seven_day: { used_percentage: 15, resets_at: 1774540000 },
seven_day_sonnet: { used_percentage: 8, resets_at: 1774540001 },
seven_day_opus: { used_percentage: 2, resets_at: 1774540002 }
}
});

expect(usageData?.weeklySonnetUsage).toBe(8);
expect(usageData?.weeklyOpusUsage).toBe(2);
expect(usageData?.weeklySonnetResetAt).toBe(new Date(1774540001 * 1000).toISOString());
expect(usageData?.weeklyOpusResetAt).toBe(new Date(1774540002 * 1000).toISOString());
expect(mockFetchUsageData.mock.calls.length).toBe(0);
});

it('falls back to API fetch when per-model widget is present but rate_limits lacks per-model buckets', async () => {
mockFetchUsageData.mockResolvedValue({
sessionUsage: 42,
sessionResetAt: '2026-03-20T12:00:00.000Z',
weeklyUsage: 15,
weeklyResetAt: '2026-03-27T12:00:00.000Z',
weeklySonnetUsage: 8,
weeklySonnetResetAt: '2026-03-27T12:00:00.000Z'
});

const lines = makeLines(
[{ id: '1', type: 'weekly-sonnet-usage' }]
);

const usageData = await prefetchUsageDataIfNeeded(lines, {
rate_limits: {
five_hour: { used_percentage: 42, resets_at: 1774020000 },
seven_day: { used_percentage: 15, resets_at: 1774540000 }
}
});

expect(usageData?.weeklySonnetUsage).toBe(8);
expect(mockFetchUsageData.mock.calls.length).toBe(1);
});

it('does not require per-model buckets when only the all-models weekly widget is present', async () => {
const lines = makeLines(
[{ id: '1', type: 'weekly-usage' }]
);

const usageData = await prefetchUsageDataIfNeeded(lines, {
rate_limits: {
five_hour: { used_percentage: 42, resets_at: 1774020000 },
seven_day: { used_percentage: 15, resets_at: 1774540000 }
}
});

expect(usageData?.weeklyUsage).toBe(15);
expect(mockFetchUsageData.mock.calls.length).toBe(0);
});

it('falls back to API fetch when sessionResetAt is missing from rate_limits', async () => {
mockFetchUsageData.mockResolvedValue({
sessionUsage: 42,
Expand Down Expand Up @@ -243,4 +307,40 @@ describe('extractUsageDataFromRateLimits', () => {

expect(result).toBeNull();
});

it('extracts per-model weekly buckets when present', () => {
const result = extractUsageDataFromRateLimits({
five_hour: { used_percentage: 42, resets_at: 1774020000 },
seven_day: { used_percentage: 15, resets_at: 1774540000 },
seven_day_sonnet: { used_percentage: 8, resets_at: 1774540001 },
seven_day_opus: { used_percentage: 2, resets_at: 1774540002 }
});

expect(result?.weeklySonnetUsage).toBe(8);
expect(result?.weeklyOpusUsage).toBe(2);
expect(result?.weeklySonnetResetAt).toBe(new Date(1774540001 * 1000).toISOString());
expect(result?.weeklyOpusResetAt).toBe(new Date(1774540002 * 1000).toISOString());
});

it('leaves per-model fields undefined when buckets are absent', () => {
const result = extractUsageDataFromRateLimits({
five_hour: { used_percentage: 42, resets_at: 1774020000 },
seven_day: { used_percentage: 15, resets_at: 1774540000 }
});

expect(result?.weeklySonnetUsage).toBeUndefined();
expect(result?.weeklyOpusUsage).toBeUndefined();
});

it('treats null per-model buckets as absent', () => {
const result = extractUsageDataFromRateLimits({
five_hour: { used_percentage: 42, resets_at: 1774020000 },
seven_day: { used_percentage: 15, resets_at: 1774540000 },
seven_day_sonnet: null,
seven_day_opus: null
});

expect(result?.weeklySonnetUsage).toBeUndefined();
expect(result?.weeklyOpusUsage).toBeUndefined();
});
});
19 changes: 19 additions & 0 deletions src/utils/usage-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,22 @@ const CachedUsageDataSchema = z.object({
sessionResetAt: z.string().nullable().optional(),
weeklyUsage: z.number().nullable().optional(),
weeklyResetAt: z.string().nullable().optional(),
weeklySonnetUsage: z.number().nullable().optional(),
weeklySonnetResetAt: z.string().nullable().optional(),
weeklyOpusUsage: z.number().nullable().optional(),
weeklyOpusResetAt: z.string().nullable().optional(),
extraUsageEnabled: z.boolean().nullable().optional(),
extraUsageLimit: z.number().nullable().optional(),
extraUsageUsed: z.number().nullable().optional(),
extraUsageUtilization: z.number().nullable().optional(),
error: z.string().nullable().optional()
});

const PerModelWeeklyBucketSchema = z.object({
utilization: z.number().nullable().optional(),
resets_at: z.string().nullable().optional()
}).nullable().optional();

const UsageApiResponseSchema = z.object({
five_hour: z.object({
utilization: z.number().nullable().optional(),
Expand All @@ -51,6 +60,8 @@ const UsageApiResponseSchema = z.object({
utilization: z.number().nullable().optional(),
resets_at: z.string().nullable().optional()
}).optional(),
seven_day_sonnet: PerModelWeeklyBucketSchema,
seven_day_opus: PerModelWeeklyBucketSchema,
extra_usage: z.object({
is_enabled: z.boolean().nullable().optional(),
monthly_limit: z.number().nullable().optional(),
Expand Down Expand Up @@ -86,6 +97,10 @@ function parseCachedUsageData(rawJson: string): UsageData | null {
sessionResetAt: parsed.sessionResetAt ?? undefined,
weeklyUsage: parsed.weeklyUsage ?? undefined,
weeklyResetAt: parsed.weeklyResetAt ?? undefined,
weeklySonnetUsage: parsed.weeklySonnetUsage ?? undefined,
weeklySonnetResetAt: parsed.weeklySonnetResetAt ?? undefined,
weeklyOpusUsage: parsed.weeklyOpusUsage ?? undefined,
weeklyOpusResetAt: parsed.weeklyOpusResetAt ?? undefined,
extraUsageEnabled: parsed.extraUsageEnabled ?? undefined,
extraUsageLimit: parsed.extraUsageLimit ?? undefined,
extraUsageUsed: parsed.extraUsageUsed ?? undefined,
Expand All @@ -105,6 +120,10 @@ function parseUsageApiResponse(rawJson: string): UsageData | null {
sessionResetAt: parsed.five_hour?.resets_at ?? undefined,
weeklyUsage: parsed.seven_day?.utilization ?? undefined,
weeklyResetAt: parsed.seven_day?.resets_at ?? undefined,
weeklySonnetUsage: parsed.seven_day_sonnet?.utilization ?? undefined,
weeklySonnetResetAt: parsed.seven_day_sonnet?.resets_at ?? undefined,
weeklyOpusUsage: parsed.seven_day_opus?.utilization ?? undefined,
weeklyOpusResetAt: parsed.seven_day_opus?.resets_at ?? undefined,
extraUsageEnabled: parsed.extra_usage?.is_enabled ?? undefined,
extraUsageLimit: parsed.extra_usage?.monthly_limit ?? undefined,
extraUsageUsed: parsed.extra_usage?.used_credits ?? undefined,
Expand Down
60 changes: 53 additions & 7 deletions src/utils/usage-prefetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,26 @@ import { fetchUsageData } from './usage';
const USAGE_WIDGET_TYPES = new Set<string>([
'session-usage',
'weekly-usage',
'weekly-sonnet-usage',
'weekly-opus-usage',
'block-timer',
'reset-timer',
'weekly-reset-timer'
]);

const PER_MODEL_USAGE_WIDGET_TYPES = new Set<string>([
'weekly-sonnet-usage',
'weekly-opus-usage'
]);

export function hasUsageDependentWidgets(lines: WidgetItem[][]): boolean {
return lines.some(line => line.some(item => USAGE_WIDGET_TYPES.has(item.type)));
}

function hasPerModelUsageWidgets(lines: WidgetItem[][]): boolean {
return lines.some(line => line.some(item => PER_MODEL_USAGE_WIDGET_TYPES.has(item.type)));
}

function epochSecondsToIsoString(epochSeconds: number | null | undefined): string | undefined {
if (epochSeconds === null || epochSeconds === undefined || !Number.isFinite(epochSeconds)) {
return undefined;
Expand All @@ -32,26 +43,61 @@ export function extractUsageDataFromRateLimits(rateLimits: StatusJSON['rate_limi
const sessionResetAt = epochSecondsToIsoString(rateLimits.five_hour?.resets_at);
const weeklyUsage = rateLimits.seven_day?.used_percentage ?? undefined;
const weeklyResetAt = epochSecondsToIsoString(rateLimits.seven_day?.resets_at);
const weeklySonnetUsage = rateLimits.seven_day_sonnet?.used_percentage ?? undefined;
const weeklySonnetResetAt = epochSecondsToIsoString(rateLimits.seven_day_sonnet?.resets_at);
const weeklyOpusUsage = rateLimits.seven_day_opus?.used_percentage ?? undefined;
const weeklyOpusResetAt = epochSecondsToIsoString(rateLimits.seven_day_opus?.resets_at);

if (sessionUsage === undefined && weeklyUsage === undefined) {
return null;
}

// Note: rate_limits does not include extra_usage data (extraUsageEnabled, etc.).
// Those fields are only available via the API fetch path.
return { sessionUsage, sessionResetAt, weeklyUsage, weeklyResetAt };
return {
sessionUsage,
sessionResetAt,
weeklyUsage,
weeklyResetAt,
weeklySonnetUsage,
weeklySonnetResetAt,
weeklyOpusUsage,
weeklyOpusResetAt
};
}

function hasCompleteRateLimitsUsageData(usageData: UsageData | null): usageData is UsageData & {
function hasCompleteRateLimitsUsageData(
usageData: UsageData | null,
perModelRequired: boolean
): usageData is UsageData & {
sessionUsage: number;
sessionResetAt: string;
weeklyUsage: number;
weeklyResetAt: string;
} {
return usageData?.sessionUsage !== undefined
&& usageData.sessionResetAt !== undefined
&& usageData.weeklyUsage !== undefined
&& usageData.weeklyResetAt !== undefined;
if (
usageData?.sessionUsage === undefined
|| usageData.sessionResetAt === undefined
|| usageData.weeklyUsage === undefined
|| usageData.weeklyResetAt === undefined
) {
return false;
}

// Per-model buckets can legitimately be absent (the user may not have used Opus,
// or the host Claude Code may be on a release that doesn't surface them yet).
// Only require that *something* — usage or reset — has been populated for
// per-model fields when a per-model widget is on screen, so we don't fall
// back to the API on every render.
if (perModelRequired) {
const sonnetPresent = usageData.weeklySonnetUsage !== undefined || usageData.weeklySonnetResetAt !== undefined;
const opusPresent = usageData.weeklyOpusUsage !== undefined || usageData.weeklyOpusResetAt !== undefined;
if (!sonnetPresent && !opusPresent) {
return false;
}
}

return true;
}

export async function prefetchUsageDataIfNeeded(lines: WidgetItem[][], data?: StatusJSON): Promise<UsageData | null> {
Expand All @@ -60,7 +106,7 @@ export async function prefetchUsageDataIfNeeded(lines: WidgetItem[][], data?: St
}

const rateLimitsData = extractUsageDataFromRateLimits(data?.rate_limits);
if (hasCompleteRateLimitsUsageData(rateLimitsData)) {
if (hasCompleteRateLimitsUsageData(rateLimitsData, hasPerModelUsageWidgets(lines))) {
return rateLimitsData;
}

Expand Down
4 changes: 4 additions & 0 deletions src/utils/usage-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ export interface UsageData {
sessionResetAt?: string; // five_hour.resets_at
weeklyUsage?: number; // seven_day.utilization (percentage)
weeklyResetAt?: string; // seven_day.resets_at
weeklySonnetUsage?: number; // seven_day_sonnet.utilization (percentage)
weeklySonnetResetAt?: string; // seven_day_sonnet.resets_at
weeklyOpusUsage?: number; // seven_day_opus.utilization (percentage)
weeklyOpusResetAt?: string; // seven_day_opus.resets_at
extraUsageEnabled?: boolean;
extraUsageLimit?: number; // in cents
extraUsageUsed?: number; // in cents
Expand Down
8 changes: 8 additions & 0 deletions src/utils/usage-windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,14 @@ export function resolveWeeklyUsageWindow(usageData: UsageData, nowMs = Date.now(
return getWeeklyUsageWindowFromResetAt(usageData.weeklyResetAt, nowMs);
}

export function resolveWeeklySonnetUsageWindow(usageData: UsageData, nowMs = Date.now()): UsageWindowMetrics | null {
return getWeeklyUsageWindowFromResetAt(usageData.weeklySonnetResetAt ?? usageData.weeklyResetAt, nowMs);
}

export function resolveWeeklyOpusUsageWindow(usageData: UsageData, nowMs = Date.now()): UsageWindowMetrics | null {
return getWeeklyUsageWindowFromResetAt(usageData.weeklyOpusResetAt ?? usageData.weeklyResetAt, nowMs);
}

export function formatUsageDuration(durationMs: number, compact = false, useDays = true): string {
const clampedMs = Math.max(0, durationMs);
const totalHours = Math.floor(clampedMs / (1000 * 60 * 60));
Expand Down
2 changes: 2 additions & 0 deletions src/utils/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export {
getWeeklyUsageWindowFromResetAt,
makeUsageProgressBar,
resolveUsageWindowWithFallback,
resolveWeeklyOpusUsageWindow,
resolveWeeklySonnetUsageWindow,
resolveWeeklyUsageWindow
} from './usage-windows';
export {
Expand Down
2 changes: 2 additions & 0 deletions src/utils/widget-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ export const WIDGET_MANIFEST: WidgetManifestEntry[] = [
{ type: 'free-memory', create: () => new widgets.FreeMemoryWidget() },
{ type: 'session-usage', create: () => new widgets.SessionUsageWidget() },
{ type: 'weekly-usage', create: () => new widgets.WeeklyUsageWidget() },
{ type: 'weekly-sonnet-usage', create: () => new widgets.WeeklySonnetUsageWidget() },
{ type: 'weekly-opus-usage', create: () => new widgets.WeeklyOpusUsageWidget() },
{ type: 'reset-timer', create: () => new widgets.BlockResetTimerWidget() },
{ type: 'weekly-reset-timer', create: () => new widgets.WeeklyResetTimerWidget() },
{ type: 'context-bar', create: () => new widgets.ContextBarWidget() },
Expand Down
Loading