From e17c8ffc1ffc18beb2e22ec5838b6f5abe965233 Mon Sep 17 00:00:00 2001 From: Zach Date: Wed, 17 Jun 2026 23:18:00 -0500 Subject: [PATCH 1/2] feat(usage): budget-aware coloring for the Extra Usage widgets Adds an optional Widget.getDynamicColor(item, context) hook so a widget can override its foreground color from live data. The renderer prefers it over the configured color at both per-widget color sites (normal and powerline); the global overrideForegroundColor still wins, and widgets that do not implement the hook are unaffected. The Extra Usage widgets (used, remaining, utilization) opt in via a (b) toggle and the budgetColors metadata flag and shift green to yellow to red as the monthly budget nears exhaustion (warn at 75 percent, critical at 90 percent utilization). The three tiers default to plain green/yellow/red and are overridable via budgetColorOk/Warn/Crit hexes. Default behavior is unchanged. Refs #467. --- src/types/Widget.ts | 5 ++ src/utils/renderer.ts | 6 +- src/widgets/ExtraUsageRemaining.ts | 17 +++++- src/widgets/ExtraUsageUsed.ts | 17 +++++- src/widgets/ExtraUsageUtilization.ts | 16 +++++- src/widgets/shared/budget-color.ts | 82 ++++++++++++++++++++++++++++ 6 files changed, 131 insertions(+), 12 deletions(-) create mode 100644 src/widgets/shared/budget-color.ts diff --git a/src/types/Widget.ts b/src/types/Widget.ts index 121c2a9b..1fa31997 100644 --- a/src/types/Widget.ts +++ b/src/types/Widget.ts @@ -46,6 +46,11 @@ export interface Widget { supportsColors(item: WidgetItem): boolean; handleEditorAction?(action: string, item: WidgetItem): WidgetItem | null; getNumericValue?(context: RenderContext, item: WidgetItem): number | null; + // Lets a widget override its foreground color from live data (e.g. budget + // severity). Returns a color to use instead of the configured one, or + // undefined to keep the static color. Resolved at the renderer's per-widget + // color sites; the global overrideForegroundColor still takes precedence. + getDynamicColor?(item: WidgetItem, context: RenderContext): string | undefined; } export interface WidgetEditorProps { diff --git a/src/utils/renderer.ts b/src/utils/renderer.ts index 47ac3914..54178cf1 100644 --- a/src/utils/renderer.ts +++ b/src/utils/renderer.ts @@ -275,7 +275,7 @@ function renderPowerlineStatusLine( const paddedText = `${leadingPadding}${widgetText}${trailingPadding}`; // Determine colors - let fgColor = widget.color ?? defaultColor; + let fgColor = widgetImpl?.getDynamicColor?.(widget, context) ?? widget.color ?? defaultColor; let bgColor = widget.backgroundColor; // Apply theme colors if a theme is set (and not 'custom') @@ -1036,13 +1036,13 @@ export function renderStatusLine( try { let widgetText: string | undefined; let defaultColor = 'white'; + const widgetImpl = getWidget(widget.type); // Use pre-rendered content const preRendered = preRenderedWidgets[i]; if (preRendered?.content) { widgetText = preRendered.content; // Get default color from widget impl for consistency - const widgetImpl = getWidget(widget.type); if (widgetImpl) { defaultColor = widgetImpl.getDefaultColor(); } @@ -1064,7 +1064,7 @@ export function renderStatusLine( } else { // Normal widget rendering with colors elements.push({ - content: applyColorsWithOverride(widgetText, widget.color ?? defaultColor, widget.backgroundColor, widget.bold, widget.dim), + content: applyColorsWithOverride(widgetText, widgetImpl?.getDynamicColor?.(widget, context) ?? widget.color ?? defaultColor, widget.backgroundColor, widget.bold, widget.dim), type: widget.type, widget }); diff --git a/src/widgets/ExtraUsageRemaining.ts b/src/widgets/ExtraUsageRemaining.ts index 6101de12..c5c8ef8c 100644 --- a/src/widgets/ExtraUsageRemaining.ts +++ b/src/widgets/ExtraUsageRemaining.ts @@ -8,6 +8,12 @@ import type { } from '../types/Widget'; import { getUsageErrorMessage } from '../utils/usage'; +import { + appendBudgetColorsModifier, + getBudgetColorsKeybind, + handleToggleBudgetColorsAction, + resolveBudgetColor +} from './shared/budget-color'; import { formatUsageCurrency } from './shared/currency'; import { appendHideDisabledModifier, @@ -26,12 +32,13 @@ export class ExtraUsageRemainingWidget implements Widget { getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName(), - modifierText: appendHideDisabledModifier(undefined, item) + modifierText: appendBudgetColorsModifier(appendHideDisabledModifier(undefined, item), item) }; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - return handleToggleExtraUsageDisabledAction(action, item); + return handleToggleExtraUsageDisabledAction(action, item) + ?? handleToggleBudgetColorsAction(action, item); } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { @@ -61,7 +68,11 @@ export class ExtraUsageRemainingWidget implements Widget { } getCustomKeybinds(): CustomKeybind[] { - return [getHideExtraUsageDisabledKeybind()]; + return [getHideExtraUsageDisabledKeybind(), getBudgetColorsKeybind()]; + } + + getDynamicColor(item: WidgetItem, context: RenderContext): string | undefined { + return resolveBudgetColor(item, context.usageData?.extraUsageUtilization); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/ExtraUsageUsed.ts b/src/widgets/ExtraUsageUsed.ts index 07e05af0..197bf41a 100644 --- a/src/widgets/ExtraUsageUsed.ts +++ b/src/widgets/ExtraUsageUsed.ts @@ -8,6 +8,12 @@ import type { } from '../types/Widget'; import { getUsageErrorMessage } from '../utils/usage'; +import { + appendBudgetColorsModifier, + getBudgetColorsKeybind, + handleToggleBudgetColorsAction, + resolveBudgetColor +} from './shared/budget-color'; import { formatUsageCurrency } from './shared/currency'; import { appendHideDisabledModifier, @@ -26,12 +32,13 @@ export class ExtraUsageUsedWidget implements Widget { getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName(), - modifierText: appendHideDisabledModifier(undefined, item) + modifierText: appendBudgetColorsModifier(appendHideDisabledModifier(undefined, item), item) }; } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - return handleToggleExtraUsageDisabledAction(action, item); + return handleToggleExtraUsageDisabledAction(action, item) + ?? handleToggleBudgetColorsAction(action, item); } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { @@ -59,7 +66,11 @@ export class ExtraUsageUsedWidget implements Widget { } getCustomKeybinds(): CustomKeybind[] { - return [getHideExtraUsageDisabledKeybind()]; + return [getHideExtraUsageDisabledKeybind(), getBudgetColorsKeybind()]; + } + + getDynamicColor(item: WidgetItem, context: RenderContext): string | undefined { + return resolveBudgetColor(item, context.usageData?.extraUsageUtilization); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/ExtraUsageUtilization.ts b/src/widgets/ExtraUsageUtilization.ts index 71dc3780..8508ed95 100644 --- a/src/widgets/ExtraUsageUtilization.ts +++ b/src/widgets/ExtraUsageUtilization.ts @@ -8,6 +8,12 @@ import type { } from '../types/Widget'; import { getUsageErrorMessage } from '../utils/usage'; +import { + appendBudgetColorsModifier, + getBudgetColorsKeybind, + handleToggleBudgetColorsAction, + resolveBudgetColor +} from './shared/budget-color'; import { appendHideDisabledModifier, getHideExtraUsageDisabledKeybind, @@ -38,7 +44,7 @@ export class ExtraUsageUtilizationWidget implements Widget { getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { return { displayText: this.getDisplayName(), - modifierText: appendHideDisabledModifier(getUsageDisplayModifierText(item), item) + modifierText: appendBudgetColorsModifier(appendHideDisabledModifier(getUsageDisplayModifierText(item), item), item) }; } @@ -56,7 +62,7 @@ export class ExtraUsageUtilizationWidget implements Widget { return toggleUsageInverted(item); } - return null; + return handleToggleBudgetColorsAction(action, item); } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { @@ -114,7 +120,11 @@ export class ExtraUsageUtilizationWidget implements Widget { } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { - return [...getUsagePercentCustomKeybinds(item), getHideExtraUsageDisabledKeybind()]; + return [...getUsagePercentCustomKeybinds(item), getHideExtraUsageDisabledKeybind(), getBudgetColorsKeybind()]; + } + + getDynamicColor(item: WidgetItem, context: RenderContext): string | undefined { + return resolveBudgetColor(item, context.usageData?.extraUsageUtilization); } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/shared/budget-color.ts b/src/widgets/shared/budget-color.ts new file mode 100644 index 00000000..cd5ef61b --- /dev/null +++ b/src/widgets/shared/budget-color.ts @@ -0,0 +1,82 @@ +import type { + CustomKeybind, + WidgetItem +} from '../../types/Widget'; + +import { + isMetadataFlagEnabled, + toggleMetadataFlag +} from './metadata'; + +const BUDGET_COLORS_KEY = 'budgetColors'; +const TOGGLE_BUDGET_COLORS_ACTION = 'toggle-budget-colors'; + +const OK_COLOR_KEY = 'budgetColorOk'; +const WARN_COLOR_KEY = 'budgetColorWarn'; +const CRIT_COLOR_KEY = 'budgetColorCrit'; + +// Utilization (used/limit, 0-100) at which the color escalates. +const WARN_AT_PERCENT = 75; +const CRIT_AT_PERCENT = 90; + +const DEFAULT_OK_COLOR = 'green'; +const DEFAULT_WARN_COLOR = 'yellow'; +const DEFAULT_CRIT_COLOR = 'red'; + +const BUDGET_COLORS_KEYBIND: CustomKeybind = { + key: 'b', + label: '(b)udget colors', + action: TOGGLE_BUDGET_COLORS_ACTION +}; + +export function isBudgetColorsEnabled(item: WidgetItem): boolean { + return isMetadataFlagEnabled(item, BUDGET_COLORS_KEY); +} + +export function handleToggleBudgetColorsAction(action: string, item: WidgetItem): WidgetItem | null { + if (action !== TOGGLE_BUDGET_COLORS_ACTION) { + return null; + } + + return toggleMetadataFlag(item, BUDGET_COLORS_KEY); +} + +export function getBudgetColorsKeybind(): CustomKeybind { + return BUDGET_COLORS_KEYBIND; +} + +export function appendBudgetColorsModifier(modifierText: string | undefined, item: WidgetItem): string | undefined { + if (!isBudgetColorsEnabled(item)) { + return modifierText; + } + + if (!modifierText) { + return '(budget colors)'; + } + + return `${modifierText.slice(0, -1)}, budget colors)`; +} + +/** + * The severity color for a budget widget at the given utilization percentage + * (used / limit, 0-100). Returns undefined when the opt-in flag is off or the + * utilization is unknown, so the widget keeps its statically configured color. + * Higher utilization is more problematic: the configured ok color below the warn + * threshold, the warn color up to critical, the critical color at or beyond it. + * Each tier defaults to a plain ANSI color and is overridable via metadata. + */ +export function resolveBudgetColor(item: WidgetItem, utilizationPercent: number | undefined): string | undefined { + if (!isBudgetColorsEnabled(item) || utilizationPercent === undefined) { + return undefined; + } + + if (utilizationPercent >= CRIT_AT_PERCENT) { + return item.metadata?.[CRIT_COLOR_KEY] ?? DEFAULT_CRIT_COLOR; + } + + if (utilizationPercent >= WARN_AT_PERCENT) { + return item.metadata?.[WARN_COLOR_KEY] ?? DEFAULT_WARN_COLOR; + } + + return item.metadata?.[OK_COLOR_KEY] ?? DEFAULT_OK_COLOR; +} From d799353c440ce07a87ce27402ec0243f7e285ee0 Mon Sep 17 00:00:00 2001 From: Zach Date: Wed, 17 Jun 2026 23:18:06 -0500 Subject: [PATCH 2/2] test(usage): cover budget-aware coloring A budget-color suite (threshold escalation, custom hex overrides, the opt-in toggle, the (b) keybind and editor modifier, and each Extra Usage widget getDynamicColor) plus the three keybind-assertion updates for the new bind. --- .../__tests__/ExtraUsageRemaining.test.ts | 3 +- src/widgets/__tests__/ExtraUsageUsed.test.ts | 3 +- .../__tests__/ExtraUsageUtilization.test.ts | 6 +- .../shared/__tests__/budget-color.test.ts | 76 +++++++++++++++++++ 4 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 src/widgets/shared/__tests__/budget-color.test.ts diff --git a/src/widgets/__tests__/ExtraUsageRemaining.test.ts b/src/widgets/__tests__/ExtraUsageRemaining.test.ts index 6cd3cb81..38ddef07 100644 --- a/src/widgets/__tests__/ExtraUsageRemaining.test.ts +++ b/src/widgets/__tests__/ExtraUsageRemaining.test.ts @@ -77,7 +77,8 @@ describe('ExtraUsageRemainingWidget', () => { const baseItem: WidgetItem = { id: 'extra', type: 'extra-usage-remaining' }; expect(widget.getCustomKeybinds()).toEqual([ - { key: 'h', label: '(h)ide if disabled', action: 'toggle-hide-disabled' } + { key: 'h', label: '(h)ide if disabled', action: 'toggle-hide-disabled' }, + { key: 'b', label: '(b)udget colors', action: 'toggle-budget-colors' } ]); expect(widget.getEditorDisplay(baseItem).modifierText).toBeUndefined(); diff --git a/src/widgets/__tests__/ExtraUsageUsed.test.ts b/src/widgets/__tests__/ExtraUsageUsed.test.ts index 05fbc0e5..8883b73d 100644 --- a/src/widgets/__tests__/ExtraUsageUsed.test.ts +++ b/src/widgets/__tests__/ExtraUsageUsed.test.ts @@ -74,7 +74,8 @@ describe('ExtraUsageUsedWidget', () => { const baseItem: WidgetItem = { id: 'extra', type: 'extra-usage-used' }; expect(widget.getCustomKeybinds()).toEqual([ - { key: 'h', label: '(h)ide if disabled', action: 'toggle-hide-disabled' } + { key: 'h', label: '(h)ide if disabled', action: 'toggle-hide-disabled' }, + { key: 'b', label: '(b)udget colors', action: 'toggle-budget-colors' } ]); expect(widget.getEditorDisplay(baseItem).modifierText).toBeUndefined(); diff --git a/src/widgets/__tests__/ExtraUsageUtilization.test.ts b/src/widgets/__tests__/ExtraUsageUtilization.test.ts index 4c000255..584f7a41 100644 --- a/src/widgets/__tests__/ExtraUsageUtilization.test.ts +++ b/src/widgets/__tests__/ExtraUsageUtilization.test.ts @@ -74,7 +74,8 @@ describe('ExtraUsageUtilizationWidget', () => { expect(widget.getCustomKeybinds(baseItem)).toEqual([ { key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' }, - { key: 'h', label: '(h)ide if disabled', action: 'toggle-hide-disabled' } + { key: 'h', label: '(h)ide if disabled', action: 'toggle-hide-disabled' }, + { key: 'b', label: '(b)udget colors', action: 'toggle-budget-colors' } ]); expect(widget.getCustomKeybinds({ ...baseItem, @@ -83,7 +84,8 @@ describe('ExtraUsageUtilizationWidget', () => { { key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' }, { key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' }, { key: 't', label: '(t)ime cursor', action: 'toggle-cursor' }, - { key: 'h', label: '(h)ide if disabled', action: 'toggle-hide-disabled' } + { key: 'h', label: '(h)ide if disabled', action: 'toggle-hide-disabled' }, + { key: 'b', label: '(b)udget colors', action: 'toggle-budget-colors' } ]); expect(widget.getEditorDisplay(baseItem).modifierText).toBeUndefined(); diff --git a/src/widgets/shared/__tests__/budget-color.test.ts b/src/widgets/shared/__tests__/budget-color.test.ts new file mode 100644 index 00000000..3b33e7e1 --- /dev/null +++ b/src/widgets/shared/__tests__/budget-color.test.ts @@ -0,0 +1,76 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import type { RenderContext } from '../../../types/RenderContext'; +import type { WidgetItem } from '../../../types/Widget'; +import { ExtraUsageRemainingWidget } from '../../ExtraUsageRemaining'; +import { ExtraUsageUsedWidget } from '../../ExtraUsageUsed'; +import { ExtraUsageUtilizationWidget } from '../../ExtraUsageUtilization'; +import { + appendBudgetColorsModifier, + getBudgetColorsKeybind, + handleToggleBudgetColorsAction, + isBudgetColorsEnabled, + resolveBudgetColor +} from '../budget-color'; + +const item = (metadata: Record = {}): WidgetItem => ({ id: 'x', type: 'extra-usage-used', metadata }); +const on = (extra: Record = {}): WidgetItem => item({ budgetColors: 'true', ...extra }); +const ctx = (utilization?: number): RenderContext => ({ usageData: { extraUsageUtilization: utilization } }); + +describe('budget-color', () => { + it('returns undefined when the opt-in flag is off', () => { + expect(isBudgetColorsEnabled(item())).toBe(false); + expect(resolveBudgetColor(item(), 95)).toBeUndefined(); + }); + + it('returns undefined when utilization is unknown', () => { + expect(resolveBudgetColor(on(), undefined)).toBeUndefined(); + }); + + it('escalates green -> yellow -> red across the thresholds', () => { + expect(resolveBudgetColor(on(), 0)).toBe('green'); + expect(resolveBudgetColor(on(), 74)).toBe('green'); + expect(resolveBudgetColor(on(), 75)).toBe('yellow'); + expect(resolveBudgetColor(on(), 89)).toBe('yellow'); + expect(resolveBudgetColor(on(), 90)).toBe('red'); + expect(resolveBudgetColor(on(), 100)).toBe('red'); + }); + + it('honors per-tier color overrides', () => { + const colors = { budgetColorOk: 'hex:00FF00', budgetColorWarn: 'hex:FFAA00', budgetColorCrit: 'hex:FF0000' }; + expect(resolveBudgetColor(on(colors), 10)).toBe('hex:00FF00'); + expect(resolveBudgetColor(on(colors), 80)).toBe('hex:FFAA00'); + expect(resolveBudgetColor(on(colors), 99)).toBe('hex:FF0000'); + }); + + it('toggles the opt-in flag via the editor action', () => { + const enabled = handleToggleBudgetColorsAction('toggle-budget-colors', item()); + expect(enabled?.metadata?.budgetColors).toBe('true'); + expect(handleToggleBudgetColorsAction('toggle-budget-colors', enabled ?? item())?.metadata?.budgetColors).toBe('false'); + expect(handleToggleBudgetColorsAction('something-else', item())).toBeNull(); + }); + + it('exposes a (b)udget colors keybind', () => { + expect(getBudgetColorsKeybind()).toEqual({ key: 'b', label: '(b)udget colors', action: 'toggle-budget-colors' }); + }); + + it('appends the modifier only when enabled', () => { + expect(appendBudgetColorsModifier(undefined, item())).toBeUndefined(); + expect(appendBudgetColorsModifier(undefined, on())).toBe('(budget colors)'); + expect(appendBudgetColorsModifier('(short bar)', on())).toBe('(short bar, budget colors)'); + }); + + it('each Extra Usage widget colors by utilization through getDynamicColor', () => { + const widgets = [new ExtraUsageUsedWidget(), new ExtraUsageRemainingWidget(), new ExtraUsageUtilizationWidget()]; + for (const widget of widgets) { + expect(widget.getDynamicColor(item(), ctx(95))).toBeUndefined(); + expect(widget.getDynamicColor(on(), ctx(50))).toBe('green'); + expect(widget.getDynamicColor(on(), ctx(95))).toBe('red'); + expect(widget.getDynamicColor(on(), ctx(undefined))).toBeUndefined(); + } + }); +});