diff --git a/docs/USAGE.md b/docs/USAGE.md index 5df60e31..8faceb41 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -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 @@ -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 diff --git a/src/types/RenderContext.ts b/src/types/RenderContext.ts index f210b1b7..baa280e6 100644 --- a/src/types/RenderContext.ts +++ b/src/types/RenderContext.ts @@ -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; diff --git a/src/types/StatusJSON.ts b/src/types/StatusJSON.ts index e2c593b8..85f2f1ca 100644 --- a/src/types/StatusJSON.ts +++ b/src/types/StatusJSON.ts @@ -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() }); diff --git a/src/utils/__tests__/usage-prefetch.test.ts b/src/utils/__tests__/usage-prefetch.test.ts index c69b7894..65c59af9 100644 --- a/src/utils/__tests__/usage-prefetch.test.ts +++ b/src/utils/__tests__/usage-prefetch.test.ts @@ -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, @@ -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(); + }); }); diff --git a/src/utils/usage-fetch.ts b/src/utils/usage-fetch.ts index a22a6e95..337cc228 100644 --- a/src/utils/usage-fetch.ts +++ b/src/utils/usage-fetch.ts @@ -35,6 +35,10 @@ 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(), @@ -42,6 +46,11 @@ const CachedUsageDataSchema = z.object({ 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(), @@ -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(), @@ -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, @@ -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, diff --git a/src/utils/usage-prefetch.ts b/src/utils/usage-prefetch.ts index 2193a971..88cabb18 100644 --- a/src/utils/usage-prefetch.ts +++ b/src/utils/usage-prefetch.ts @@ -7,15 +7,26 @@ import { fetchUsageData } from './usage'; const USAGE_WIDGET_TYPES = new Set([ '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([ + '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; @@ -32,6 +43,10 @@ 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; @@ -39,19 +54,50 @@ export function extractUsageDataFromRateLimits(rateLimits: StatusJSON['rate_limi // 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 { @@ -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; } diff --git a/src/utils/usage-types.ts b/src/utils/usage-types.ts index a976c183..c00161dd 100644 --- a/src/utils/usage-types.ts +++ b/src/utils/usage-types.ts @@ -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 diff --git a/src/utils/usage-windows.ts b/src/utils/usage-windows.ts index adca9775..603824a6 100644 --- a/src/utils/usage-windows.ts +++ b/src/utils/usage-windows.ts @@ -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)); diff --git a/src/utils/usage.ts b/src/utils/usage.ts index 10e65ca0..cb4264ff 100644 --- a/src/utils/usage.ts +++ b/src/utils/usage.ts @@ -8,6 +8,8 @@ export { getWeeklyUsageWindowFromResetAt, makeUsageProgressBar, resolveUsageWindowWithFallback, + resolveWeeklyOpusUsageWindow, + resolveWeeklySonnetUsageWindow, resolveWeeklyUsageWindow } from './usage-windows'; export { diff --git a/src/utils/widget-manifest.ts b/src/utils/widget-manifest.ts index ac8cb87f..cd1c90a4 100644 --- a/src/utils/widget-manifest.ts +++ b/src/utils/widget-manifest.ts @@ -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() }, diff --git a/src/widgets/WeeklyOpusUsage.ts b/src/widgets/WeeklyOpusUsage.ts new file mode 100644 index 00000000..998596c3 --- /dev/null +++ b/src/widgets/WeeklyOpusUsage.ts @@ -0,0 +1,127 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + getUsageErrorMessage, + resolveWeeklyOpusUsageWindow +} from '../utils/usage'; + +import { makeTimerProgressBar } from './shared/progress-bar'; +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; +import { + cycleUsageDisplayMode, + getUsageDisplayMode, + getUsageDisplayModifierText, + getUsagePercentCustomKeybinds, + getUsageProgressBarWidth, + isUsageCursorEnabled, + isUsageInverted, + isUsageProgressMode, + isUsageSliderMode, + makeSliderBar, + toggleUsageCursor, + toggleUsageInverted +} from './shared/usage-display'; + +const LABEL = 'Weekly Opus: '; + +export class WeeklyOpusUsageWidget implements Widget { + getDefaultColor(): string { return 'brightBlue'; } + getDescription(): string { return 'Shows weekly Opus API usage percentage'; } + getDisplayName(): string { return 'Weekly Opus Usage'; } + getCategory(): string { return 'Usage'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { + displayText: this.getDisplayName(), + modifierText: getUsageDisplayModifierText(item) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + if (action === 'toggle-progress') { + return cycleUsageDisplayMode(item, [], true); + } + + if (action === 'toggle-invert') { + return toggleUsageInverted(item); + } + + if (action === 'toggle-cursor') { + return toggleUsageCursor(item); + } + + return null; + } + + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + const displayMode = getUsageDisplayMode(item); + const inverted = isUsageInverted(item); + const showCursor = isUsageCursorEnabled(item); + + if (context.isPreview) { + const previewPercent = 4; + const renderedPercent = inverted ? 100 - previewPercent : previewPercent; + + if (isUsageProgressMode(displayMode)) { + const width = getUsageProgressBarWidth(displayMode); + const progressBar = makeTimerProgressBar(renderedPercent, width, showCursor ? { cursorPercent: 50 } : undefined); + const progressDisplay = `[${progressBar}] ${renderedPercent.toFixed(1)}%`; + return formatRawOrLabeledValue(item, LABEL, progressDisplay); + } + + if (isUsageSliderMode(displayMode)) { + const slider = makeSliderBar(renderedPercent, undefined, showCursor ? { cursorPercent: 50 } : undefined); + const sliderDisplay = displayMode === 'slider' ? `${slider} ${renderedPercent.toFixed(1)}%` : slider; + return formatRawOrLabeledValue(item, LABEL, sliderDisplay); + } + + return formatRawOrLabeledValue(item, LABEL, `${previewPercent.toFixed(1)}%`); + } + + const data = context.usageData ?? {}; + if (data.error) + return getUsageErrorMessage(data.error); + if (data.weeklyOpusUsage === undefined) + return null; + + const percent = Math.max(0, Math.min(100, data.weeklyOpusUsage)); + const renderedPercent = inverted ? 100 - percent : percent; + const getCursorOptions = (): { cursorPercent: number } | undefined => { + if (!showCursor) { + return undefined; + } + + const window = resolveWeeklyOpusUsageWindow(data); + return window ? { cursorPercent: window.elapsedPercent } : undefined; + }; + + if (isUsageProgressMode(displayMode)) { + const width = getUsageProgressBarWidth(displayMode); + + const progressBar = makeTimerProgressBar(renderedPercent, width, getCursorOptions()); + const progressDisplay = `[${progressBar}] ${renderedPercent.toFixed(1)}%`; + return formatRawOrLabeledValue(item, LABEL, progressDisplay); + } + + if (isUsageSliderMode(displayMode)) { + const slider = makeSliderBar(renderedPercent, undefined, getCursorOptions()); + const sliderDisplay = displayMode === 'slider' ? `${slider} ${renderedPercent.toFixed(1)}%` : slider; + return formatRawOrLabeledValue(item, LABEL, sliderDisplay); + } + + return formatRawOrLabeledValue(item, LABEL, `${percent.toFixed(1)}%`); + } + + getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { + return getUsagePercentCustomKeybinds(item); + } + + supportsRawValue(): boolean { return true; } + supportsColors(item: WidgetItem): boolean { return true; } +} diff --git a/src/widgets/WeeklySonnetUsage.ts b/src/widgets/WeeklySonnetUsage.ts new file mode 100644 index 00000000..88204126 --- /dev/null +++ b/src/widgets/WeeklySonnetUsage.ts @@ -0,0 +1,127 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + CustomKeybind, + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; +import { + getUsageErrorMessage, + resolveWeeklySonnetUsageWindow +} from '../utils/usage'; + +import { makeTimerProgressBar } from './shared/progress-bar'; +import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; +import { + cycleUsageDisplayMode, + getUsageDisplayMode, + getUsageDisplayModifierText, + getUsagePercentCustomKeybinds, + getUsageProgressBarWidth, + isUsageCursorEnabled, + isUsageInverted, + isUsageProgressMode, + isUsageSliderMode, + makeSliderBar, + toggleUsageCursor, + toggleUsageInverted +} from './shared/usage-display'; + +const LABEL = 'Weekly Sonnet: '; + +export class WeeklySonnetUsageWidget implements Widget { + getDefaultColor(): string { return 'brightBlue'; } + getDescription(): string { return 'Shows weekly Sonnet API usage percentage'; } + getDisplayName(): string { return 'Weekly Sonnet Usage'; } + getCategory(): string { return 'Usage'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { + displayText: this.getDisplayName(), + modifierText: getUsageDisplayModifierText(item) + }; + } + + handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + if (action === 'toggle-progress') { + return cycleUsageDisplayMode(item, [], true); + } + + if (action === 'toggle-invert') { + return toggleUsageInverted(item); + } + + if (action === 'toggle-cursor') { + return toggleUsageCursor(item); + } + + return null; + } + + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + const displayMode = getUsageDisplayMode(item); + const inverted = isUsageInverted(item); + const showCursor = isUsageCursorEnabled(item); + + if (context.isPreview) { + const previewPercent = 8; + const renderedPercent = inverted ? 100 - previewPercent : previewPercent; + + if (isUsageProgressMode(displayMode)) { + const width = getUsageProgressBarWidth(displayMode); + const progressBar = makeTimerProgressBar(renderedPercent, width, showCursor ? { cursorPercent: 50 } : undefined); + const progressDisplay = `[${progressBar}] ${renderedPercent.toFixed(1)}%`; + return formatRawOrLabeledValue(item, LABEL, progressDisplay); + } + + if (isUsageSliderMode(displayMode)) { + const slider = makeSliderBar(renderedPercent, undefined, showCursor ? { cursorPercent: 50 } : undefined); + const sliderDisplay = displayMode === 'slider' ? `${slider} ${renderedPercent.toFixed(1)}%` : slider; + return formatRawOrLabeledValue(item, LABEL, sliderDisplay); + } + + return formatRawOrLabeledValue(item, LABEL, `${previewPercent.toFixed(1)}%`); + } + + const data = context.usageData ?? {}; + if (data.error) + return getUsageErrorMessage(data.error); + if (data.weeklySonnetUsage === undefined) + return null; + + const percent = Math.max(0, Math.min(100, data.weeklySonnetUsage)); + const renderedPercent = inverted ? 100 - percent : percent; + const getCursorOptions = (): { cursorPercent: number } | undefined => { + if (!showCursor) { + return undefined; + } + + const window = resolveWeeklySonnetUsageWindow(data); + return window ? { cursorPercent: window.elapsedPercent } : undefined; + }; + + if (isUsageProgressMode(displayMode)) { + const width = getUsageProgressBarWidth(displayMode); + + const progressBar = makeTimerProgressBar(renderedPercent, width, getCursorOptions()); + const progressDisplay = `[${progressBar}] ${renderedPercent.toFixed(1)}%`; + return formatRawOrLabeledValue(item, LABEL, progressDisplay); + } + + if (isUsageSliderMode(displayMode)) { + const slider = makeSliderBar(renderedPercent, undefined, getCursorOptions()); + const sliderDisplay = displayMode === 'slider' ? `${slider} ${renderedPercent.toFixed(1)}%` : slider; + return formatRawOrLabeledValue(item, LABEL, sliderDisplay); + } + + return formatRawOrLabeledValue(item, LABEL, `${percent.toFixed(1)}%`); + } + + getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { + return getUsagePercentCustomKeybinds(item); + } + + supportsRawValue(): boolean { return true; } + supportsColors(item: WidgetItem): boolean { return true; } +} diff --git a/src/widgets/__tests__/WeeklyOpusUsage.test.ts b/src/widgets/__tests__/WeeklyOpusUsage.test.ts new file mode 100644 index 00000000..8fcb7eaa --- /dev/null +++ b/src/widgets/__tests__/WeeklyOpusUsage.test.ts @@ -0,0 +1,105 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import { DEFAULT_SETTINGS } from '../../types/Settings'; +import type { WidgetItem } from '../../types/Widget'; +import * as usage from '../../utils/usage'; +import type { UsageWindowMetrics } from '../../utils/usage-types'; +import { WeeklyOpusUsageWidget } from '../WeeklyOpusUsage'; + +import { runUsagePercentWidgetSuite } from './helpers/usage-widget-suites'; + +let mockGetUsageErrorMessage: { mockReturnValue: (value: string) => void }; +const usageErrorMessageMock = { + mockReturnValue(value: string): void { + mockGetUsageErrorMessage.mockReturnValue(value); + } +}; + +const halfElapsedWindow: UsageWindowMetrics = { + sessionDurationMs: 604800000, + elapsedMs: 302400000, + remainingMs: 302400000, + elapsedPercent: 50, + remainingPercent: 50 +}; + +function render(widget: WeeklyOpusUsageWidget, item: WidgetItem, context: RenderContext = {}): string | null { + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('WeeklyOpusUsageWidget', () => { + beforeEach(() => { + vi.restoreAllMocks(); + mockGetUsageErrorMessage = vi.spyOn(usage, 'getUsageErrorMessage'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders the time cursor in short bar modes', () => { + const widget = new WeeklyOpusUsageWidget(); + const context: RenderContext = { usageData: { weeklyOpusUsage: 20 } }; + + vi.spyOn(usage, 'resolveWeeklyOpusUsageWindow').mockReturnValue(halfElapsedWindow); + + expect(render(widget, { + id: 'weekly-opus', + type: 'weekly-opus-usage', + metadata: { cursor: 'true', display: 'slider' } + }, context)).toBe('Weekly Opus: ▓▓░░░│░░░░ 20.0%'); + expect(render(widget, { + id: 'weekly-opus', + type: 'weekly-opus-usage', + metadata: { cursor: 'true', display: 'slider-only' } + }, context)).toBe('Weekly Opus: ▓▓░░░│░░░░'); + }); + + it('returns null when the per-model usage is missing from the API response', () => { + const widget = new WeeklyOpusUsageWidget(); + expect(render(widget, { id: 'weekly-opus', type: 'weekly-opus-usage' }, { usageData: {} })).toBeNull(); + }); + + runUsagePercentWidgetSuite({ + baseItem: { id: 'weekly-opus', type: 'weekly-opus-usage' }, + createWidget: () => new WeeklyOpusUsageWidget(), + errorMessageMock: usageErrorMessageMock, + expectedModifierText: '(long bar, inverted)', + expectedProgress: 'Weekly Opus: [███████████████████░░░░░░░░░░░░░] 57.9%', + expectedRawProgress: '[███████░░░░░░░░░] 42.1%', + expectedRawTime: '42.1%', + expectedTime: 'Weekly Opus: 42.1%', + modifierItem: { + id: 'weekly-opus', + type: 'weekly-opus-usage', + metadata: { display: 'progress', invert: 'true' } + }, + progressItem: { + id: 'weekly-opus', + type: 'weekly-opus-usage', + metadata: { display: 'progress', invert: 'true' } + }, + rawProgressItem: { + id: 'weekly-opus', + type: 'weekly-opus-usage', + rawValue: true, + metadata: { display: 'progress-short' } + }, + rawTimeItem: { + id: 'weekly-opus', + type: 'weekly-opus-usage', + rawValue: true + }, + render, + usageField: 'weeklyOpusUsage', + usageValue: 42.06 + }); +}); diff --git a/src/widgets/__tests__/WeeklySonnetUsage.test.ts b/src/widgets/__tests__/WeeklySonnetUsage.test.ts new file mode 100644 index 00000000..5cce7332 --- /dev/null +++ b/src/widgets/__tests__/WeeklySonnetUsage.test.ts @@ -0,0 +1,105 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; + +import type { RenderContext } from '../../types/RenderContext'; +import { DEFAULT_SETTINGS } from '../../types/Settings'; +import type { WidgetItem } from '../../types/Widget'; +import * as usage from '../../utils/usage'; +import type { UsageWindowMetrics } from '../../utils/usage-types'; +import { WeeklySonnetUsageWidget } from '../WeeklySonnetUsage'; + +import { runUsagePercentWidgetSuite } from './helpers/usage-widget-suites'; + +let mockGetUsageErrorMessage: { mockReturnValue: (value: string) => void }; +const usageErrorMessageMock = { + mockReturnValue(value: string): void { + mockGetUsageErrorMessage.mockReturnValue(value); + } +}; + +const halfElapsedWindow: UsageWindowMetrics = { + sessionDurationMs: 604800000, + elapsedMs: 302400000, + remainingMs: 302400000, + elapsedPercent: 50, + remainingPercent: 50 +}; + +function render(widget: WeeklySonnetUsageWidget, item: WidgetItem, context: RenderContext = {}): string | null { + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('WeeklySonnetUsageWidget', () => { + beforeEach(() => { + vi.restoreAllMocks(); + mockGetUsageErrorMessage = vi.spyOn(usage, 'getUsageErrorMessage'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders the time cursor in short bar modes', () => { + const widget = new WeeklySonnetUsageWidget(); + const context: RenderContext = { usageData: { weeklySonnetUsage: 20 } }; + + vi.spyOn(usage, 'resolveWeeklySonnetUsageWindow').mockReturnValue(halfElapsedWindow); + + expect(render(widget, { + id: 'weekly-sonnet', + type: 'weekly-sonnet-usage', + metadata: { cursor: 'true', display: 'slider' } + }, context)).toBe('Weekly Sonnet: ▓▓░░░│░░░░ 20.0%'); + expect(render(widget, { + id: 'weekly-sonnet', + type: 'weekly-sonnet-usage', + metadata: { cursor: 'true', display: 'slider-only' } + }, context)).toBe('Weekly Sonnet: ▓▓░░░│░░░░'); + }); + + it('returns null when the per-model usage is missing from the API response', () => { + const widget = new WeeklySonnetUsageWidget(); + expect(render(widget, { id: 'weekly-sonnet', type: 'weekly-sonnet-usage' }, { usageData: {} })).toBeNull(); + }); + + runUsagePercentWidgetSuite({ + baseItem: { id: 'weekly-sonnet', type: 'weekly-sonnet-usage' }, + createWidget: () => new WeeklySonnetUsageWidget(), + errorMessageMock: usageErrorMessageMock, + expectedModifierText: '(long bar, inverted)', + expectedProgress: 'Weekly Sonnet: [███████████████████░░░░░░░░░░░░░] 57.9%', + expectedRawProgress: '[███████░░░░░░░░░] 42.1%', + expectedRawTime: '42.1%', + expectedTime: 'Weekly Sonnet: 42.1%', + modifierItem: { + id: 'weekly-sonnet', + type: 'weekly-sonnet-usage', + metadata: { display: 'progress', invert: 'true' } + }, + progressItem: { + id: 'weekly-sonnet', + type: 'weekly-sonnet-usage', + metadata: { display: 'progress', invert: 'true' } + }, + rawProgressItem: { + id: 'weekly-sonnet', + type: 'weekly-sonnet-usage', + rawValue: true, + metadata: { display: 'progress-short' } + }, + rawTimeItem: { + id: 'weekly-sonnet', + type: 'weekly-sonnet-usage', + rawValue: true + }, + render, + usageField: 'weeklySonnetUsage', + usageValue: 42.06 + }); +}); diff --git a/src/widgets/__tests__/helpers/usage-widget-suites.ts b/src/widgets/__tests__/helpers/usage-widget-suites.ts index f5f3ed1e..a3e3b5db 100644 --- a/src/widgets/__tests__/helpers/usage-widget-suites.ts +++ b/src/widgets/__tests__/helpers/usage-widget-suites.ts @@ -33,7 +33,7 @@ interface UsagePercentWidgetSuiteConfig { rawProgressItem: WidgetItem; rawTimeItem: WidgetItem; render: (widget: TWidget, item: WidgetItem, context?: RenderContext) => string | null; - usageField: 'sessionUsage' | 'weeklyUsage'; + usageField: 'sessionUsage' | 'weeklyUsage' | 'weeklySonnetUsage' | 'weeklyOpusUsage'; usageValue: number; } @@ -68,10 +68,8 @@ const EXPECTED_TIMER_PROGRESS_KEYBINDS: CustomKeybind[] = [ { key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' } ]; -function getUsageContext(field: 'sessionUsage' | 'weeklyUsage', value: number): RenderContext { - return field === 'sessionUsage' - ? { usageData: { sessionUsage: value } } - : { usageData: { weeklyUsage: value } }; +function getUsageContext(field: 'sessionUsage' | 'weeklyUsage' | 'weeklySonnetUsage' | 'weeklyOpusUsage', value: number): RenderContext { + return { usageData: { [field]: value } }; } export function runUsagePercentWidgetSuite(config: UsagePercentWidgetSuiteConfig): void { diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 0ae3ba2b..c11b2254 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -59,6 +59,8 @@ export { FreeMemoryWidget } from './FreeMemory'; export { SessionNameWidget } from './SessionName'; export { SessionUsageWidget } from './SessionUsage'; export { WeeklyUsageWidget } from './WeeklyUsage'; +export { WeeklySonnetUsageWidget } from './WeeklySonnetUsage'; +export { WeeklyOpusUsageWidget } from './WeeklyOpusUsage'; export { BlockResetTimerWidget } from './BlockResetTimer'; export { WeeklyResetTimerWidget } from './WeeklyResetTimer'; export { ContextBarWidget } from './ContextBar';