From 1577a9c8e4ea76fc8a9c3a27368e6e3d3d39b20f Mon Sep 17 00:00:00 2001 From: Griffin Yourick Date: Wed, 23 Sep 2026 12:54:26 -0400 Subject: [PATCH 1/5] feat(usage): add hide-when-zero state to usage widgets and reset timers Session, Weekly, Weekly Sonnet, Weekly Opus and Weekly Fable usage widgets, plus the Block and Weekly reset timers, gain an opt-in `zero` hideable state. Usage widgets hide when their raw usage is 0 (independent of invert); reset timers hide when their period's usage (session/weekly) is 0. Co-Authored-By: Claude Opus 5.5 --- src/widgets/BlockResetTimer.ts | 7 ++++- src/widgets/FableWeeklyUsage.ts | 3 ++- src/widgets/SessionUsage.ts | 3 ++- src/widgets/WeeklyOpusUsage.ts | 3 ++- src/widgets/WeeklyResetTimer.ts | 7 ++++- src/widgets/WeeklySonnetUsage.ts | 3 ++- src/widgets/WeeklyUsage.ts | 3 ++- src/widgets/__tests__/BlockResetTimer.test.ts | 22 +++++++++++++-- .../__tests__/WeeklyResetTimer.test.ts | 22 +++++++++++++-- .../__tests__/helpers/usage-widget-suites.ts | 27 +++++++++++++++++++ src/widgets/shared/usage-display.ts | 1 + src/widgets/shared/usage-percent-widget.ts | 5 ++++ 12 files changed, 95 insertions(+), 11 deletions(-) diff --git a/src/widgets/BlockResetTimer.ts b/src/widgets/BlockResetTimer.ts index 1a9782adf..25d65d569 100644 --- a/src/widgets/BlockResetTimer.ts +++ b/src/widgets/BlockResetTimer.ts @@ -34,6 +34,7 @@ import { } from './shared/timezone-editor'; import { USAGE_NO_DATA_HIDEABLE_STATE, + USAGE_ZERO_HIDEABLE_STATE, cycleUsageDisplayMode, getUsageDisplayMode, getUsageDisplayModifierText, @@ -71,7 +72,7 @@ export class BlockResetTimerWidget implements Widget { } getHideableStates(): HideableState[] { - return [USAGE_NO_DATA_HIDEABLE_STATE]; + return [USAGE_NO_DATA_HIDEABLE_STATE, USAGE_ZERO_HIDEABLE_STATE]; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { @@ -137,6 +138,10 @@ export class BlockResetTimerWidget implements Widget { } const usageData = context.usageData ?? {}; + if (usageData.sessionUsage === 0 && isHidden(item, USAGE_ZERO_HIDEABLE_STATE.key)) { + return null; + } + const window = resolveUsageWindowWithFallback(usageData, context.blockMetrics); if (!window) { diff --git a/src/widgets/FableWeeklyUsage.ts b/src/widgets/FableWeeklyUsage.ts index 86dc78345..f9cea9bb2 100644 --- a/src/widgets/FableWeeklyUsage.ts +++ b/src/widgets/FableWeeklyUsage.ts @@ -10,6 +10,7 @@ import type { import { USAGE_NO_DATA_HIDEABLE_STATE, + USAGE_ZERO_HIDEABLE_STATE, getUsagePercentCustomKeybinds } from './shared/usage-display'; import { @@ -31,7 +32,7 @@ export class FableWeeklyUsageWidget implements Widget { } getHideableStates(): HideableState[] { - return [USAGE_NO_DATA_HIDEABLE_STATE]; + return [USAGE_NO_DATA_HIDEABLE_STATE, USAGE_ZERO_HIDEABLE_STATE]; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { diff --git a/src/widgets/SessionUsage.ts b/src/widgets/SessionUsage.ts index 774231348..91e3ac613 100644 --- a/src/widgets/SessionUsage.ts +++ b/src/widgets/SessionUsage.ts @@ -10,6 +10,7 @@ import type { import { USAGE_NO_DATA_HIDEABLE_STATE, + USAGE_ZERO_HIDEABLE_STATE, getUsagePercentCustomKeybinds } from './shared/usage-display'; import { @@ -31,7 +32,7 @@ export class SessionUsageWidget implements Widget { } getHideableStates(): HideableState[] { - return [USAGE_NO_DATA_HIDEABLE_STATE]; + return [USAGE_NO_DATA_HIDEABLE_STATE, USAGE_ZERO_HIDEABLE_STATE]; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { diff --git a/src/widgets/WeeklyOpusUsage.ts b/src/widgets/WeeklyOpusUsage.ts index 150d0d463..bd4308898 100644 --- a/src/widgets/WeeklyOpusUsage.ts +++ b/src/widgets/WeeklyOpusUsage.ts @@ -10,6 +10,7 @@ import type { import { USAGE_NO_DATA_HIDEABLE_STATE, + USAGE_ZERO_HIDEABLE_STATE, getUsagePercentCustomKeybinds } from './shared/usage-display'; import { @@ -31,7 +32,7 @@ export class WeeklyOpusUsageWidget implements Widget { } getHideableStates(): HideableState[] { - return [USAGE_NO_DATA_HIDEABLE_STATE]; + return [USAGE_NO_DATA_HIDEABLE_STATE, USAGE_ZERO_HIDEABLE_STATE]; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { diff --git a/src/widgets/WeeklyResetTimer.ts b/src/widgets/WeeklyResetTimer.ts index bdeb4e8d8..415d2539d 100644 --- a/src/widgets/WeeklyResetTimer.ts +++ b/src/widgets/WeeklyResetTimer.ts @@ -39,6 +39,7 @@ import { } from './shared/timezone-editor'; import { USAGE_NO_DATA_HIDEABLE_STATE, + USAGE_ZERO_HIDEABLE_STATE, cycleUsageDisplayMode, getUsageDisplayMode, getUsageLocale, @@ -141,7 +142,7 @@ export class WeeklyResetTimerWidget implements Widget { } getHideableStates(): HideableState[] { - return [USAGE_NO_DATA_HIDEABLE_STATE]; + return [USAGE_NO_DATA_HIDEABLE_STATE, USAGE_ZERO_HIDEABLE_STATE]; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { @@ -221,6 +222,10 @@ export class WeeklyResetTimerWidget implements Widget { } const usageData = context.usageData ?? {}; + if (usageData.weeklyUsage === 0 && isHidden(item, USAGE_ZERO_HIDEABLE_STATE.key)) { + return null; + } + const window = resolveWeeklyUsageWindow(usageData); if (!window) { diff --git a/src/widgets/WeeklySonnetUsage.ts b/src/widgets/WeeklySonnetUsage.ts index 96eff8cc7..b36e73421 100644 --- a/src/widgets/WeeklySonnetUsage.ts +++ b/src/widgets/WeeklySonnetUsage.ts @@ -10,6 +10,7 @@ import type { import { USAGE_NO_DATA_HIDEABLE_STATE, + USAGE_ZERO_HIDEABLE_STATE, getUsagePercentCustomKeybinds } from './shared/usage-display'; import { @@ -31,7 +32,7 @@ export class WeeklySonnetUsageWidget implements Widget { } getHideableStates(): HideableState[] { - return [USAGE_NO_DATA_HIDEABLE_STATE]; + return [USAGE_NO_DATA_HIDEABLE_STATE, USAGE_ZERO_HIDEABLE_STATE]; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { diff --git a/src/widgets/WeeklyUsage.ts b/src/widgets/WeeklyUsage.ts index 86544d557..ce4c3eb41 100644 --- a/src/widgets/WeeklyUsage.ts +++ b/src/widgets/WeeklyUsage.ts @@ -10,6 +10,7 @@ import type { import { USAGE_NO_DATA_HIDEABLE_STATE, + USAGE_ZERO_HIDEABLE_STATE, getUsagePercentCustomKeybinds } from './shared/usage-display'; import { @@ -31,7 +32,7 @@ export class WeeklyUsageWidget implements Widget { } getHideableStates(): HideableState[] { - return [USAGE_NO_DATA_HIDEABLE_STATE]; + return [USAGE_NO_DATA_HIDEABLE_STATE, USAGE_ZERO_HIDEABLE_STATE]; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { diff --git a/src/widgets/__tests__/BlockResetTimer.test.ts b/src/widgets/__tests__/BlockResetTimer.test.ts index a930bcf10..9398c90d6 100644 --- a/src/widgets/__tests__/BlockResetTimer.test.ts +++ b/src/widgets/__tests__/BlockResetTimer.test.ts @@ -119,8 +119,26 @@ describe('BlockResetTimerWidget', () => { expect(render(widget, { id: 'reset', type: 'reset-timer', rawValue: true }, { usageData: {} })).toBe('[Loading]'); }); - it('declares the no-data hideable state', () => { - expect(new BlockResetTimerWidget().getHideableStates().map(state => state.key)).toEqual(['no-data']); + it('declares the no-data and zero hideable states', () => { + expect(new BlockResetTimerWidget().getHideableStates().map(state => state.key)).toEqual(['no-data', 'zero']); + }); + + it('hides when sessionUsage is zero and the zero state is enabled', () => { + const widget = new BlockResetTimerWidget(); + const item: WidgetItem = { id: 'reset', type: 'reset-timer', metadata: { hide: 'zero' } }; + + mockResolveUsageWindowWithFallback.mockReturnValue({ + sessionDurationMs: 18000000, + elapsedMs: 3600000, + remainingMs: 14400000, + elapsedPercent: 20, + remainingPercent: 80 + }); + mockFormatUsageDuration.mockReturnValue('4hr'); + + expect(render(widget, item, { usageData: { sessionUsage: 0 } })).toBeNull(); + expect(render(widget, item, { usageData: { sessionUsage: 5 } })).toBe('Reset: 4hr'); + expect(render(widget, { id: 'reset', type: 'reset-timer' }, { usageData: { sessionUsage: 0 } })).toBe('Reset: 4hr'); }); // One state covers both placeholders, since either means the same thing to diff --git a/src/widgets/__tests__/WeeklyResetTimer.test.ts b/src/widgets/__tests__/WeeklyResetTimer.test.ts index e35a9d7d3..919313cee 100644 --- a/src/widgets/__tests__/WeeklyResetTimer.test.ts +++ b/src/widgets/__tests__/WeeklyResetTimer.test.ts @@ -150,8 +150,26 @@ describe('WeeklyResetTimerWidget', () => { expect(render(widget, { id: 'weekly-reset', type: 'weekly-reset-timer', rawValue: true }, { usageData: {} })).toBe('[Loading]'); }); - it('declares the no-data hideable state', () => { - expect(new WeeklyResetTimerWidget().getHideableStates().map(state => state.key)).toEqual(['no-data']); + it('declares the no-data and zero hideable states', () => { + expect(new WeeklyResetTimerWidget().getHideableStates().map(state => state.key)).toEqual(['no-data', 'zero']); + }); + + it('hides when weeklyUsage is zero and the zero state is enabled', () => { + const widget = new WeeklyResetTimerWidget(); + const item: WidgetItem = { id: 'weekly-reset', type: 'weekly-reset-timer', metadata: { hide: 'zero' } }; + + mockResolveWeeklyUsageWindow.mockReturnValue({ + sessionDurationMs: 18000000, + elapsedMs: 3600000, + remainingMs: 14400000, + elapsedPercent: 20, + remainingPercent: 80 + }); + mockFormatUsageDuration.mockReturnValue('4hr'); + + expect(render(widget, item, { usageData: { weeklyUsage: 0 } })).toBeNull(); + expect(render(widget, item, { usageData: { weeklyUsage: 5 } })).toBe('Weekly Reset: 4hr'); + expect(render(widget, { id: 'weekly-reset', type: 'weekly-reset-timer' }, { usageData: { weeklyUsage: 0 } })).toBe('Weekly Reset: 4hr'); }); // One state covers both placeholders, since either means the same thing to diff --git a/src/widgets/__tests__/helpers/usage-widget-suites.ts b/src/widgets/__tests__/helpers/usage-widget-suites.ts index 9ec6790a2..d14e70672 100644 --- a/src/widgets/__tests__/helpers/usage-widget-suites.ts +++ b/src/widgets/__tests__/helpers/usage-widget-suites.ts @@ -8,6 +8,7 @@ import { import type { RenderContext } from '../../../types/RenderContext'; import type { CustomKeybind, + HideableState, WidgetEditorDisplay, WidgetItem } from '../../../types/Widget'; @@ -15,6 +16,7 @@ import type { interface UsageWidgetLike { getCustomKeybinds(item?: WidgetItem): CustomKeybind[]; getEditorDisplay(item: WidgetItem): WidgetEditorDisplay; + getHideableStates(): HideableState[]; handleEditorAction(action: string, item: WidgetItem): WidgetItem | null; supportsRawValue(): boolean; } @@ -149,6 +151,31 @@ export function runUsagePercentWidgetSuite(conf }, { usageData: { error: 'timeout' } })).toBeNull(); }); + it('declares the no-data and zero hideable states', () => { + expect(config.createWidget().getHideableStates().map(state => state.key)).toEqual(['no-data', 'zero']); + }); + + it('hides zero usage when the zero state is enabled, regardless of invert', () => { + const widget = config.createWidget(); + const context = getUsageContext(config.usageField, 0); + + expect(config.render(widget, { ...config.baseItem, metadata: { hide: 'zero' } }, context)).toBeNull(); + expect(config.render(widget, { ...config.baseItem, metadata: { hide: 'zero', invert: 'true' } }, context)).toBeNull(); + }); + + it('renders zero usage when the zero state is off', () => { + const widget = config.createWidget(); + + expect(config.render(widget, config.baseItem, getUsageContext(config.usageField, 0))).not.toBeNull(); + }); + + it('renders non-zero usage when the zero state is enabled', () => { + const widget = config.createWidget(); + const context = getUsageContext(config.usageField, config.usageValue); + + expect(config.render(widget, { ...config.baseItem, metadata: { hide: 'zero' } }, context)).toBe(config.expectedTime); + }); + it('renders available usage data before unrelated usage errors', () => { const widget = config.createWidget(); const context: RenderContext = { diff --git a/src/widgets/shared/usage-display.ts b/src/widgets/shared/usage-display.ts index b2ad166a2..32426a54c 100644 --- a/src/widgets/shared/usage-display.ts +++ b/src/widgets/shared/usage-display.ts @@ -20,6 +20,7 @@ export type UsageDisplayMode = 'time' | 'progress' | 'progress-short' | 'slider' // Shared by the usage percentage widgets and the reset timers, which render the // same error placeholders export const USAGE_NO_DATA_HIDEABLE_STATE: HideableState = { key: 'no-data', label: 'when usage data is unavailable' }; +export const USAGE_ZERO_HIDEABLE_STATE: HideableState = { key: 'zero', label: 'when usage is zero' }; const SLIDER_WIDTH = 10; diff --git a/src/widgets/shared/usage-percent-widget.ts b/src/widgets/shared/usage-percent-widget.ts index 8e12728ef..78089a103 100644 --- a/src/widgets/shared/usage-percent-widget.ts +++ b/src/widgets/shared/usage-percent-widget.ts @@ -27,6 +27,7 @@ import { makeTimerProgressBar } from './progress-bar'; import { formatRawOrLabeledValue } from './raw-or-labeled'; import { USAGE_NO_DATA_HIDEABLE_STATE, + USAGE_ZERO_HIDEABLE_STATE, cycleUsageDisplayMode, getUsageDisplayMode, getUsageDisplayModifierText, @@ -194,6 +195,10 @@ export function renderUsagePercentWidgetValue( } const percent = Math.max(0, Math.min(100, usagePercent)); + if (percent === 0 && isHidden(item, USAGE_ZERO_HIDEABLE_STATE.key)) { + return null; + } + const renderedPercent = inverted ? 100 - percent : percent; return renderUsageDisplay(item, config.label, renderedPercent, format, () => { From 9c2a00bce7f71a5dd9faccfbd11a3da93c853e57 Mon Sep 17 00:00:00 2001 From: Griffin Yourick Date: Wed, 23 Sep 2026 13:15:55 -0400 Subject: [PATCH 2/5] feat(usage): add hide-when-zero state to Extra Usage Used and Utilization Both widgets gain the opt-in `zero` hideable state: Used hides at $0 spent, Utilization hides at 0% (independent of invert). Extra Usage Remaining is left alone, since hiding it would hide either the full budget or the $0-left warning. The v3 -> v4 hide migration rule for the two widgets now lists `zero` so a hand-written hide list keeps it. Co-Authored-By: Claude Opus 5.5 --- src/utils/migrations.ts | 6 ++++-- src/widgets/ExtraUsageUsed.ts | 11 +++++++++-- src/widgets/ExtraUsageUtilization.ts | 7 ++++++- src/widgets/__tests__/ExtraUsageUsed.test.ts | 13 +++++++++++-- src/widgets/__tests__/ExtraUsageUtilization.test.ts | 13 ++++++++++++- 5 files changed, 42 insertions(+), 8 deletions(-) diff --git a/src/utils/migrations.ts b/src/utils/migrations.ts index 25700e9b6..039d1212d 100644 --- a/src/utils/migrations.ts +++ b/src/utils/migrations.ts @@ -161,6 +161,8 @@ const GIT_REVIEW_HIDE_RULE: HideFlagRule = { // placeholder. hideIfDisabled never covered that placeholder, so it stays mapped // to 'disabled' alone and only a hand-written list can enable it. const EXTRA_USAGE_HIDE_RULE: HideFlagRule = { legacy: { hideIfDisabled: ['disabled'] }, stateOrder: ['disabled', 'no-data'] }; +// Used and Utilization also offer `zero`, which a hand-written hide list can carry in. +const EXTRA_USAGE_ZERO_HIDE_RULE: HideFlagRule = { legacy: { hideIfDisabled: ['disabled'] }, stateOrder: ['disabled', 'no-data', 'zero'] }; const CACHE_HIDE_RULE: HideFlagRule = { legacy: { hideWhenEmpty: ['empty'] }, stateOrder: ['empty'] }; export const V4_HIDE_FLAG_RULES: Record = { @@ -209,9 +211,9 @@ export const V4_HIDE_FLAG_RULES: Record = { 'cache-write': CACHE_HIDE_RULE, 'cache-hit-rate': CACHE_HIDE_RULE, 'cache-timer': CACHE_HIDE_RULE, - 'extra-usage-utilization': EXTRA_USAGE_HIDE_RULE, + 'extra-usage-utilization': EXTRA_USAGE_ZERO_HIDE_RULE, 'extra-usage-remaining': EXTRA_USAGE_HIDE_RULE, - 'extra-usage-used': EXTRA_USAGE_HIDE_RULE + 'extra-usage-used': EXTRA_USAGE_ZERO_HIDE_RULE }; const V4_LEGACY_HIDE_KEYS = [ diff --git a/src/widgets/ExtraUsageUsed.ts b/src/widgets/ExtraUsageUsed.ts index 8faaba7fd..02649a233 100644 --- a/src/widgets/ExtraUsageUsed.ts +++ b/src/widgets/ExtraUsageUsed.ts @@ -13,7 +13,10 @@ import { formatUsageCurrency } from './shared/currency'; import { EXTRA_USAGE_DISABLED_HIDEABLE_STATE } from './shared/extra-usage-disabled'; import { isHidden } from './shared/hideable'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; -import { USAGE_NO_DATA_HIDEABLE_STATE } from './shared/usage-display'; +import { + USAGE_NO_DATA_HIDEABLE_STATE, + USAGE_ZERO_HIDEABLE_STATE +} from './shared/usage-display'; export class ExtraUsageUsedWidget implements Widget { getDefaultColor(): string { return 'green'; } @@ -26,7 +29,7 @@ export class ExtraUsageUsedWidget implements Widget { } getHideableStates(): HideableState[] { - return [EXTRA_USAGE_DISABLED_HIDEABLE_STATE, USAGE_NO_DATA_HIDEABLE_STATE]; + return [EXTRA_USAGE_DISABLED_HIDEABLE_STATE, USAGE_NO_DATA_HIDEABLE_STATE, USAGE_ZERO_HIDEABLE_STATE]; } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { @@ -50,6 +53,10 @@ export class ExtraUsageUsedWidget implements Widget { return null; } + if (data.extraUsageUsed === 0 && isHidden(item, USAGE_ZERO_HIDEABLE_STATE.key)) { + return null; + } + // extraUsageUsed is in cents const usedDollars = data.extraUsageUsed / 100; const formatted = formatUsageCurrency(usedDollars, data.extraUsageCurrency, format); diff --git a/src/widgets/ExtraUsageUtilization.ts b/src/widgets/ExtraUsageUtilization.ts index 851b7c65f..41e3e7655 100644 --- a/src/widgets/ExtraUsageUtilization.ts +++ b/src/widgets/ExtraUsageUtilization.ts @@ -19,6 +19,7 @@ import { makeTimerProgressBar } from './shared/progress-bar'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; import { USAGE_NO_DATA_HIDEABLE_STATE, + USAGE_ZERO_HIDEABLE_STATE, cycleUsageDisplayMode, getUsageDisplayMode, getUsageDisplayModifierText, @@ -45,7 +46,7 @@ export class ExtraUsageUtilizationWidget implements Widget { } getHideableStates(): HideableState[] { - return [EXTRA_USAGE_DISABLED_HIDEABLE_STATE, USAGE_NO_DATA_HIDEABLE_STATE]; + return [EXTRA_USAGE_DISABLED_HIDEABLE_STATE, USAGE_NO_DATA_HIDEABLE_STATE, USAGE_ZERO_HIDEABLE_STATE]; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { @@ -101,6 +102,10 @@ export class ExtraUsageUtilizationWidget implements Widget { // extraUsageUtilization is already a percentage (0-100), not a fraction const percent = Math.max(0, Math.min(100, data.extraUsageUtilization)); + if (percent === 0 && isHidden(item, USAGE_ZERO_HIDEABLE_STATE.key)) { + return null; + } + const renderedPercent = inverted ? 100 - percent : percent; if (isUsageProgressMode(displayMode)) { diff --git a/src/widgets/__tests__/ExtraUsageUsed.test.ts b/src/widgets/__tests__/ExtraUsageUsed.test.ts index 70be2b521..71e19beaf 100644 --- a/src/widgets/__tests__/ExtraUsageUsed.test.ts +++ b/src/widgets/__tests__/ExtraUsageUsed.test.ts @@ -95,13 +95,22 @@ describe('ExtraUsageUsedWidget', () => { expect(widget.supportsNumberFormat()).toBe(true); }); - it('declares the disabled and no-data hideable states', () => { + it('declares the disabled, no-data and zero hideable states', () => { const widget = new ExtraUsageUsedWidget(); - expect(widget.getHideableStates().map(state => state.key)).toEqual(['disabled', 'no-data']); + expect(widget.getHideableStates().map(state => state.key)).toEqual(['disabled', 'no-data', 'zero']); expect(widget.getEditorDisplay({ id: 'extra', type: 'extra-usage-used' }).modifierText).toBeUndefined(); }); + it('hides zero spend only when the zero state is enabled', () => { + const widget = new ExtraUsageUsedWidget(); + const hideZeroItem: WidgetItem = { id: 'extra', type: 'extra-usage-used', metadata: { hide: 'zero' } }; + + expect(render(widget, hideZeroItem, { usageData: { extraUsageEnabled: true, extraUsageUsed: 0 } })).toBeNull(); + expect(render(widget, hideZeroItem, { usageData: { extraUsageEnabled: true, extraUsageUsed: 10600 } })).toBe('Overage Used: $106.00'); + expect(render(widget, { id: 'extra', type: 'extra-usage-used' }, { usageData: { extraUsageEnabled: true, extraUsageUsed: 0 } })).toBe('Overage Used: $0.00'); + }); + it('renders available used budget before unrelated usage errors', () => { const widget = new ExtraUsageUsedWidget(); diff --git a/src/widgets/__tests__/ExtraUsageUtilization.test.ts b/src/widgets/__tests__/ExtraUsageUtilization.test.ts index b4f8fe54f..ffbacd1c7 100644 --- a/src/widgets/__tests__/ExtraUsageUtilization.test.ts +++ b/src/widgets/__tests__/ExtraUsageUtilization.test.ts @@ -96,7 +96,18 @@ describe('ExtraUsageUtilizationWidget', () => { metadata: { invert: 'true' } }).modifierText).toBe('(remaining)'); - expect(widget.getHideableStates().map(state => state.key)).toEqual(['disabled', 'no-data']); + expect(widget.getHideableStates().map(state => state.key)).toEqual(['disabled', 'no-data', 'zero']); + }); + + it('hides zero utilization only when the zero state is enabled, regardless of invert', () => { + const widget = new ExtraUsageUtilizationWidget(); + const zeroContext: RenderContext = { usageData: { extraUsageEnabled: true, extraUsageUtilization: 0 } }; + + expect(render(widget, { id: 'extra', type: 'extra-usage-utilization', metadata: { hide: 'zero' } }, zeroContext)).toBeNull(); + expect(render(widget, { id: 'extra', type: 'extra-usage-utilization', metadata: { hide: 'zero', invert: 'true' } }, zeroContext)).toBeNull(); + const nonZeroContext: RenderContext = { usageData: { extraUsageEnabled: true, extraUsageUtilization: 25 } }; + expect(render(widget, { id: 'extra', type: 'extra-usage-utilization', metadata: { hide: 'zero' } }, nonZeroContext)).toBe('Overage: 25.0%'); + expect(render(widget, { id: 'extra', type: 'extra-usage-utilization' }, zeroContext)).toBe('Overage: 0.0%'); }); it('shows usage errors only when required extra usage data is missing', () => { From e86016e3c78359ca63251c6639bf0fd12b008be7 Mon Sep 17 00:00:00 2001 From: Griffin Yourick Date: Wed, 23 Sep 2026 13:24:11 -0400 Subject: [PATCH 3/5] docs(usage): document the zero hide state and tighten its test List the new `zero` state for the usage-percent widgets, the reset timers, and Extra Usage Used/Utilization (noting Remaining has none). The shared suite's "zero state off" case now asserts the exact 0.0% output instead of just non-null. Co-Authored-By: Claude Opus 5.5 --- docs/USAGE.md | 6 +++--- src/widgets/__tests__/helpers/usage-widget-suites.ts | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/USAGE.md b/docs/USAGE.md index 62cd88bcf..bbd025a20 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -310,13 +310,13 @@ Supported states by widget family: - **Session Cost**: `zero` hides `$0.00` - **Session Clock**: `zero` hides durations under one minute - **Block Timer**: `no-data` hides the `0hr 0m` / empty-bar display when no block is active -- **Block Reset Timer / Weekly Reset Timer**: `no-data` hides both the `[Loading]` placeholder and the usage-error placeholders while no reset window is available +- **Block Reset Timer / Weekly Reset Timer**: `no-data` hides both the `[Loading]` placeholder and the usage-error placeholders while no reset window is available, `zero` hides the timer while its period's usage (session or weekly) is 0% - **Input/Output/Total Speed**: `no-data` hides the `—` placeholder when no speed data exists - **Output Style**: `default-value` hides the widget when the style is `default` - **Compaction Counter**: `zero` hides the counter before any compaction occurs - **Skills**: `empty` hides the widget before any skill is used -- **Extra Usage widgets**: `disabled` hides the `n/a` display when extra usage is off, `no-data` hides the error placeholder when usage data is unavailable -- **Session / Weekly / Weekly Sonnet / Weekly Opus / Weekly Fable Usage**: `no-data` hides the error placeholder (`[No credentials]`, `[Timeout]`, `[Rate limited]`, `[API Error]`, `[Parse Error]`) when usage data is unavailable +- **Extra Usage widgets**: `disabled` hides the `n/a` display when extra usage is off, `no-data` hides the error placeholder when usage data is unavailable; on Extra Usage Used and Extra Usage Utilization, `zero` hides the widget while nothing has been spent (Extra Usage Remaining has no `zero` state) +- **Session / Weekly / Weekly Sonnet / Weekly Opus / Weekly Fable Usage**: `no-data` hides the error placeholder (`[No credentials]`, `[Timeout]`, `[Rate limited]`, `[API Error]`, `[Parse Error]`) when usage data is unavailable, `zero` hides the widget while usage is 0% (also when showing remaining) - **Cache widgets** (`Cache Hit Rate`, `Cache Read`, `Cache Write`, `Cache Timer`): `empty` hides the widget when there is no cache activity, and on Cache Timer when no cache anchor is available - **Custom Text / Custom Symbol**: `merge-target-hidden` hides the item when the widget it is merged with renders nothing, so icon prefixes/suffixes disappear together with their widget diff --git a/src/widgets/__tests__/helpers/usage-widget-suites.ts b/src/widgets/__tests__/helpers/usage-widget-suites.ts index d14e70672..e9fbd39d9 100644 --- a/src/widgets/__tests__/helpers/usage-widget-suites.ts +++ b/src/widgets/__tests__/helpers/usage-widget-suites.ts @@ -166,7 +166,9 @@ export function runUsagePercentWidgetSuite(conf it('renders zero usage when the zero state is off', () => { const widget = config.createWidget(); - expect(config.render(widget, config.baseItem, getUsageContext(config.usageField, 0))).not.toBeNull(); + const expectedZeroTime = config.expectedTime.replace(config.expectedRawTime, '0.0%'); + + expect(config.render(widget, config.baseItem, getUsageContext(config.usageField, 0))).toBe(expectedZeroTime); }); it('renders non-zero usage when the zero state is enabled', () => { From 1142c48aeb267e2007d5849dd19b7dfe5320cce7 Mon Sep 17 00:00:00 2001 From: Griffin Yourick Date: Wed, 23 Sep 2026 13:38:30 -0400 Subject: [PATCH 4/5] fix(usage): keep reset timers visible when a zero reading has a reset time A null API bucket pins session/weekly usage to 0% even when limits[] still reports a live window (#503), so hide-when-zero was hiding reset timers that had a valid countdown. The timers now hide on zero only when no reset time was reported for that period, which still covers unused windows and Enterprise accounts with no rate-limit windows (#343). Co-Authored-By: Claude Opus 5.5 --- docs/USAGE.md | 2 +- src/widgets/BlockResetTimer.ts | 2 +- src/widgets/WeeklyResetTimer.ts | 2 +- src/widgets/__tests__/BlockResetTimer.test.ts | 2 ++ src/widgets/__tests__/WeeklyResetTimer.test.ts | 2 ++ 5 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/USAGE.md b/docs/USAGE.md index bbd025a20..c5ab3f632 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -310,7 +310,7 @@ Supported states by widget family: - **Session Cost**: `zero` hides `$0.00` - **Session Clock**: `zero` hides durations under one minute - **Block Timer**: `no-data` hides the `0hr 0m` / empty-bar display when no block is active -- **Block Reset Timer / Weekly Reset Timer**: `no-data` hides both the `[Loading]` placeholder and the usage-error placeholders while no reset window is available, `zero` hides the timer while its period's usage (session or weekly) is 0% +- **Block Reset Timer / Weekly Reset Timer**: `no-data` hides both the `[Loading]` placeholder and the usage-error placeholders while no reset window is available, `zero` hides the timer while its period's usage (session or weekly) is 0% and no reset window has started - **Input/Output/Total Speed**: `no-data` hides the `—` placeholder when no speed data exists - **Output Style**: `default-value` hides the widget when the style is `default` - **Compaction Counter**: `zero` hides the counter before any compaction occurs diff --git a/src/widgets/BlockResetTimer.ts b/src/widgets/BlockResetTimer.ts index 25d65d569..19b7e3a3d 100644 --- a/src/widgets/BlockResetTimer.ts +++ b/src/widgets/BlockResetTimer.ts @@ -138,7 +138,7 @@ export class BlockResetTimerWidget implements Widget { } const usageData = context.usageData ?? {}; - if (usageData.sessionUsage === 0 && isHidden(item, USAGE_ZERO_HIDEABLE_STATE.key)) { + if (usageData.sessionUsage === 0 && !usageData.sessionResetAt && isHidden(item, USAGE_ZERO_HIDEABLE_STATE.key)) { return null; } diff --git a/src/widgets/WeeklyResetTimer.ts b/src/widgets/WeeklyResetTimer.ts index 415d2539d..0482ff42a 100644 --- a/src/widgets/WeeklyResetTimer.ts +++ b/src/widgets/WeeklyResetTimer.ts @@ -222,7 +222,7 @@ export class WeeklyResetTimerWidget implements Widget { } const usageData = context.usageData ?? {}; - if (usageData.weeklyUsage === 0 && isHidden(item, USAGE_ZERO_HIDEABLE_STATE.key)) { + if (usageData.weeklyUsage === 0 && !usageData.weeklyResetAt && isHidden(item, USAGE_ZERO_HIDEABLE_STATE.key)) { return null; } diff --git a/src/widgets/__tests__/BlockResetTimer.test.ts b/src/widgets/__tests__/BlockResetTimer.test.ts index 9398c90d6..7a372769b 100644 --- a/src/widgets/__tests__/BlockResetTimer.test.ts +++ b/src/widgets/__tests__/BlockResetTimer.test.ts @@ -137,6 +137,8 @@ describe('BlockResetTimerWidget', () => { mockFormatUsageDuration.mockReturnValue('4hr'); expect(render(widget, item, { usageData: { sessionUsage: 0 } })).toBeNull(); + // A null bucket pins usage to 0 while limits[] still supplies the reset time + expect(render(widget, item, { usageData: { sessionUsage: 0, sessionResetAt: '2030-07-01T00:00:00.000Z' } })).toBe('Reset: 4hr'); expect(render(widget, item, { usageData: { sessionUsage: 5 } })).toBe('Reset: 4hr'); expect(render(widget, { id: 'reset', type: 'reset-timer' }, { usageData: { sessionUsage: 0 } })).toBe('Reset: 4hr'); }); diff --git a/src/widgets/__tests__/WeeklyResetTimer.test.ts b/src/widgets/__tests__/WeeklyResetTimer.test.ts index 919313cee..aeaf08125 100644 --- a/src/widgets/__tests__/WeeklyResetTimer.test.ts +++ b/src/widgets/__tests__/WeeklyResetTimer.test.ts @@ -168,6 +168,8 @@ describe('WeeklyResetTimerWidget', () => { mockFormatUsageDuration.mockReturnValue('4hr'); expect(render(widget, item, { usageData: { weeklyUsage: 0 } })).toBeNull(); + // A null bucket pins usage to 0 while limits[] still supplies the reset time + expect(render(widget, item, { usageData: { weeklyUsage: 0, weeklyResetAt: '2030-07-01T00:00:00.000Z' } })).toBe('Weekly Reset: 4hr'); expect(render(widget, item, { usageData: { weeklyUsage: 5 } })).toBe('Weekly Reset: 4hr'); expect(render(widget, { id: 'weekly-reset', type: 'weekly-reset-timer' }, { usageData: { weeklyUsage: 0 } })).toBe('Weekly Reset: 4hr'); }); From 260e91035193957b14091eb13cdd4fdd56f4b6d4 Mon Sep 17 00:00:00 2001 From: axisrow Date: Tue, 29 Sep 2026 11:59:35 +0800 Subject: [PATCH 5/5] test(cli): end-to-end pipe smoke test with real git fixtures Spawn the real CLI in piped mode (bun src/ccstatusline.ts --config ) against real git fixture repos and assert on stdout: no-git outside a repository, ahead/behind counts for a diverged branch, hidden zero state on a synced branch, and the no-upstream state. The suite uses a temp HOME (isolates the on-disk git cache), a minimal fixed settings.json (does not depend on the developer's live config or the default widget set), and points git at the fixture via the payload cwd field. Runs outside the test process like custom-command-process, since other suites mock child_process globally under Bun. Co-Authored-By: Claude Code --- src/__tests__/cli-smoke.test.ts | 108 ++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 src/__tests__/cli-smoke.test.ts diff --git a/src/__tests__/cli-smoke.test.ts b/src/__tests__/cli-smoke.test.ts new file mode 100644 index 000000000..1108d54df --- /dev/null +++ b/src/__tests__/cli-smoke.test.ts @@ -0,0 +1,108 @@ +import type * as childProcess from 'child_process'; +import * as fs from 'node:fs'; +import { createRequire } from 'node:module'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + afterAll, + describe, + expect, + it +} from 'vitest'; + +// End-to-end smoke test: spawns the real CLI in piped mode against real git +// fixtures. Must run outside the test process because other suites mock +// child_process globally under Bun. +const require = createRequire(import.meta.url); +const { execFileSync } = require('node:child_process') as typeof childProcess; + +const ANSI_CODES = /\x1b\[[0-9;]*m/g; +// The renderer emits non-breaking spaces inside widget text; normalize them +// so plain-ASCII assertions stay readable. +const NBSP = new RegExp(String.fromCharCode(160), 'g'); +const entryPath = fileURLToPath(new URL('../ccstatusline.ts', import.meta.url)); +const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-cli-smoke-')); +const tempHome = path.join(tempRoot, 'home'); +const configPath = path.join(tempRoot, 'settings.json'); +const transcriptPath = path.join(tempRoot, 'transcript.jsonl'); + +// Fixed minimal config so the smoke does not depend on the developer's live +// settings or on the default widget set. +fs.mkdirSync(tempHome, { recursive: true }); +fs.writeFileSync(configPath, JSON.stringify({ + version: 4, + lines: [[ + { id: 'smoke-branch', type: 'git-branch' }, + { id: 'smoke-changes', type: 'git-changes' }, + { id: 'smoke-ahead-behind', type: 'git-ahead-behind' } + ]] +}), 'utf-8'); +fs.writeFileSync(transcriptPath, '', 'utf-8'); + +function git(cwd: string, ...args: string[]): void { + execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', ...args], { + cwd, + stdio: 'pipe' + }); +} + +function initRepo(name: string): string { + const repoPath = path.join(tempRoot, name); + fs.mkdirSync(repoPath, { recursive: true }); + git(repoPath, 'init', '-b', 'main', '--quiet'); + git(repoPath, 'commit', '--allow-empty', '--quiet', '-m', 'init'); + return repoPath; +} + +function createDivergedClone(): string { + const originPath = initRepo('smoke-origin'); + const workPath = path.join(tempRoot, 'smoke-work'); + git(tempRoot, 'clone', '--quiet', originPath, workPath); + git(workPath, 'commit', '--allow-empty', '--quiet', '-m', 'local'); + git(originPath, 'commit', '--allow-empty', '--quiet', '-m', 'remote'); + git(workPath, 'fetch', '--quiet', 'origin'); + return workPath; +} + +function renderLine(cwd: string): string { + const stdout = execFileSync('bun', [entryPath, '--config', configPath], { + input: JSON.stringify({ + model: { id: 'claude-sonnet-4-5' }, + transcript_path: transcriptPath, + cwd + }), + encoding: 'utf8', + timeout: 20000, + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, HOME: tempHome, USERPROFILE: tempHome } + }); + return stdout.replace(ANSI_CODES, '').replace(NBSP, ' '); +} + +afterAll(() => { + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +describe('CLI pipe smoke (spawned process, real git fixtures)', () => { + it('renders the no-git state outside a repository', () => { + expect(renderLine(tempRoot)).toContain('no git'); + }); + + it('renders ahead and behind counts for a diverged branch', () => { + const text = renderLine(createDivergedClone()); + expect(text).toContain('↑1'); + expect(text).toContain('↓1'); + }); + + it('hides the zero state on a synced branch', () => { + const originPath = initRepo('smoke-synced-origin'); + const clonePath = path.join(tempRoot, 'smoke-synced'); + git(tempRoot, 'clone', '--quiet', originPath, clonePath); + expect(renderLine(clonePath)).not.toContain('↑0↓0'); + }); + + it('renders the no-upstream state without an upstream', () => { + expect(renderLine(initRepo('smoke-noupstream'))).toContain('(no upstream)'); + }); +});