From ab59c71210842c34f51f162e3440cbc6e52d1f04 Mon Sep 17 00:00:00 2001 From: Zach Date: Tue, 16 Jun 2026 00:53:41 -0500 Subject: [PATCH 1/3] feat(number-format): configurable per-widget numeric precision Add a per-widget `numberFormat` ({ style: precise|compact|whole, decimals }) and an optional per-kind global (Settings.numberFormat), resolved by resolveNumberFormat(kind, item, settings) with a set global winning over the per-widget value (matching overrideForegroundColor / globalBold). Every numeric formatter routes through renderMagnitude with a type-appropriate baseline, so default output is unchanged: tokens/speed/percent stay 1 decimal, cost 2, memory G1/M0/K0. Styles: precise keeps trailing zeros (1.0M, today's default); compact trims them (1M, 1.1M); whole drops decimals (1M). Threaded through every numeric widget (tokens, context length/window/bar, compaction reclaimed, cache read/write/hit, free memory, session cost, speeds, context %, usage sliders, block/weekly timers). TUI: ColorMenu (n) cycles a widget's style, Global Overrides has a per-type submenu, and decimals is set in settings.json. Optional schema fields, so no settings migration. Co-Authored-By: Claude --- src/tui/components/ColorMenu.tsx | 16 ++- src/tui/components/GlobalOverridesMenu.tsx | 87 ++++++++++++ .../color-menu/__tests__/mutations.test.ts | 38 +++++- src/tui/components/color-menu/mutations.ts | 40 ++++++ src/types/NumberFormat.ts | 33 +++++ src/types/Settings.ts | 2 + src/types/Widget.ts | 2 + src/utils/__tests__/number-format.test.ts | 128 ++++++++++++++++++ .../__tests__/renderer-format-tokens.test.ts | 8 +- src/utils/format-tokens.ts | 27 ++-- src/utils/number-format.ts | 47 +++++++ src/utils/speed-metrics.ts | 11 +- src/widgets/BlockResetTimer.ts | 14 +- src/widgets/BlockTimer.ts | 14 +- src/widgets/CacheHitRate.ts | 7 +- src/widgets/CacheRead.ts | 5 +- src/widgets/CacheWrite.ts | 5 +- src/widgets/CompactionCounter.ts | 16 ++- src/widgets/ContextBar.ts | 15 +- src/widgets/ContextLength.ts | 6 +- src/widgets/ContextPercentage.ts | 9 +- src/widgets/ContextPercentageUsable.ts | 9 +- src/widgets/ContextWindow.ts | 4 +- src/widgets/ExtraUsageUtilization.ts | 17 ++- src/widgets/FreeMemory.ts | 16 ++- src/widgets/InputSpeed.ts | 3 +- src/widgets/OutputSpeed.ts | 3 +- src/widgets/SessionCost.ts | 8 +- src/widgets/SessionUsage.ts | 17 ++- src/widgets/TokensCached.ts | 4 +- src/widgets/TokensInput.ts | 6 +- src/widgets/TokensOutput.ts | 6 +- src/widgets/TokensTotal.ts | 4 +- src/widgets/TotalSpeed.ts | 3 +- src/widgets/WeeklyOpusUsage.ts | 17 ++- src/widgets/WeeklyResetTimer.ts | 14 +- src/widgets/WeeklySonnetUsage.ts | 17 ++- src/widgets/WeeklyUsage.ts | 17 ++- src/widgets/shared/cache-metrics.ts | 13 +- src/widgets/shared/context-slider.ts | 6 +- src/widgets/shared/speed-widget.tsx | 12 +- 41 files changed, 609 insertions(+), 117 deletions(-) create mode 100644 src/types/NumberFormat.ts create mode 100644 src/utils/__tests__/number-format.test.ts create mode 100644 src/utils/number-format.ts diff --git a/src/tui/components/ColorMenu.tsx b/src/tui/components/ColorMenu.tsx index 41a941b4..af239267 100644 --- a/src/tui/components/ColorMenu.tsx +++ b/src/tui/components/ColorMenu.tsx @@ -24,6 +24,7 @@ import { clearAllWidgetStyling, cycleWidgetColor, cycleWidgetDim, + cycleWidgetNumberStyle, resetWidgetStyling, setWidgetColor, toggleWidgetBold @@ -278,6 +279,15 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin onUpdate(newItems); } } + } else if (input === 'n' || input === 'N') { + if (highlightedItemId && highlightedItemId !== 'back') { + // Cycle number format style for the highlighted item: default -> compact -> whole -> default + const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId); + if (selectedWidget) { + const newItems = cycleWidgetNumberStyle(widgets, selectedWidget.id); + onUpdate(newItems); + } + } } else if (input === 'r' || input === 'R') { if (highlightedItemId && highlightedItemId !== 'back') { // Reset all styling (color, background, and bold) for the highlighted item @@ -444,7 +454,9 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin const styleIndicators = [ selectedWidget?.bold ? '[BOLD]' : null, selectedWidget?.dim === true ? '[DIM]' : null, - selectedWidget?.dim === 'parens' ? '[DIM ()]' : null + selectedWidget?.dim === 'parens' ? '[DIM ()]' : null, + selectedWidget?.numberFormat?.style ? `[#${selectedWidget.numberFormat.style}]` : null, + selectedWidget?.numberFormat?.decimals !== undefined ? `[#${selectedWidget.numberFormat.decimals}dp]` : null ].filter(indicator => indicator !== null).join(' '); // Gradient selection mode takes over the whole view @@ -588,7 +600,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin ↑↓ to select, ←→ to cycle {' '} {editingBackground ? 'background' : 'foreground'} - , (f) to toggle bg/fg, (b)old, (d)im, + , (f) to toggle bg/fg, (b)old, (d)im, (n)umber, {settings.colorLevel === 3 ? ' (h)ex,' : settings.colorLevel === 2 ? ' (a)nsi256,' : ''} {!editingBackground && settings.colorLevel >= 2 ? ' (g)radient,' : ''} {' '} diff --git a/src/tui/components/GlobalOverridesMenu.tsx b/src/tui/components/GlobalOverridesMenu.tsx index facfd283..defdc6ce 100644 --- a/src/tui/components/GlobalOverridesMenu.tsx +++ b/src/tui/components/GlobalOverridesMenu.tsx @@ -6,6 +6,13 @@ import { import React, { useState } from 'react'; import { getColorLevelString } from '../../types/ColorLevel'; +import { + NUMBER_KINDS, + type GlobalNumberFormat, + type NumberFormat, + type NumberKind, + type NumberStyle +} from '../../types/NumberFormat'; import { DefaultPaddingSideSchema, type Settings @@ -21,6 +28,34 @@ import { shouldInsertInput } from '../../utils/input-guards'; import { ConfirmDialog } from './ConfirmDialog'; +const NUMBER_FORMAT_STYLES: (NumberStyle | undefined)[] = [undefined, 'compact', 'whole']; + +// Cycle a number kind's global style: default (precise) -> compact -> whole -> default. +// A global style forces that kind across all widgets (see resolveNumberFormat). +function cycleGlobalNumberStyle(settings: Settings, kind: NumberKind): Settings { + const current = settings.numberFormat?.[kind]?.style; + const currentIndex = NUMBER_FORMAT_STYLES.indexOf(current); + const nextStyle = NUMBER_FORMAT_STYLES[(currentIndex + 1) % NUMBER_FORMAT_STYLES.length]; + + const kindFormat: NumberFormat = { ...settings.numberFormat?.[kind] }; + if (nextStyle === undefined) { + delete kindFormat.style; + } else { + kindFormat.style = nextStyle; + } + + const { [kind]: removedKind, ...restGlobal } = settings.numberFormat ?? {}; + void removedKind; // Intentionally unused + const nextGlobal: GlobalNumberFormat = Object.keys(kindFormat).length > 0 + ? { ...restGlobal, [kind]: kindFormat } + : restGlobal; + + return { + ...settings, + numberFormat: Object.keys(nextGlobal).length > 0 ? nextGlobal : undefined + }; +} + export interface GlobalOverridesMenuProps { settings: Settings; onUpdate: (settings: Settings) => void; @@ -36,6 +71,8 @@ export const GlobalOverridesMenu: React.FC = ({ settin const [inheritColors, setInheritColors] = useState(settings.inheritSeparatorColors); const [globalBold, setGlobalBold] = useState(settings.globalBold); const [minimalistMode, setMinimalistMode] = useState(settings.minimalistMode); + const [numberFormatMode, setNumberFormatMode] = useState(false); + const [numberFormatKindIndex, setNumberFormatKindIndex] = useState(0); const [gradientMode, setGradientMode] = useState(false); const [gradientIndex, setGradientIndex] = useState(0); const [gradientCustomStep, setGradientCustomStep] = useState<'start' | 'end' | null>(null); @@ -162,6 +199,19 @@ export const GlobalOverridesMenu: React.FC = ({ settin setGradientCustomStep('start'); } } + } else if (numberFormatMode) { + if (key.escape) { + setNumberFormatMode(false); + } else if (key.upArrow) { + setNumberFormatKindIndex((numberFormatKindIndex - 1 + NUMBER_KINDS.length) % NUMBER_KINDS.length); + } else if (key.downArrow) { + setNumberFormatKindIndex((numberFormatKindIndex + 1) % NUMBER_KINDS.length); + } else if (key.leftArrow || key.rightArrow) { + const kind = NUMBER_KINDS[numberFormatKindIndex]; + if (kind) { + onUpdate(cycleGlobalNumberStyle(settings, kind)); + } + } } else { if (key.escape) { onBack(); @@ -211,6 +261,9 @@ export const GlobalOverridesMenu: React.FC = ({ settin minimalistMode: newMinimalistMode }; onUpdate(updatedSettings); + } else if (input === 'n' || input === 'N') { + setNumberFormatMode(true); + setNumberFormatKindIndex(0); } else if (input === 'f' || input === 'F') { // Cycle through foreground colors const nextIndex = (currentFgIndex + 1) % fgColors.length; @@ -248,6 +301,34 @@ export const GlobalOverridesMenu: React.FC = ({ settin } }); + if (numberFormatMode) { + return ( + + Global Number Formatting + + ↑↓ to select a number type, ←→ to cycle its style, ESC to go back + + + {NUMBER_KINDS.map((kind, idx) => { + const style = settings.numberFormat?.[kind]?.style ?? 'precise (default)'; + return ( + + {idx === numberFormatKindIndex ? '▶ ' : ' '} + {kind} + {': '} + {style} + + ); + })} + + + precise = keep trailing zeros (1.0M), compact = trim them (1M / 1.1M), whole = no decimals (1M). + A global style forces that type across every widget. Decimal places are set per-widget or in settings.json. + + + ); + } + if (gradientMode) { const level = getColorLevelString(settings.colorLevel); @@ -372,6 +453,12 @@ export const GlobalOverridesMenu: React.FC = ({ settin - Press (m) to toggle + + Number Formatting: + {settings.numberFormat ? 'customized' : '(defaults)'} + - Press (n) to configure per-type + + Default Padding: {settings.defaultPadding ? `"${settings.defaultPadding}"` : '(none)'} diff --git a/src/tui/components/color-menu/__tests__/mutations.test.ts b/src/tui/components/color-menu/__tests__/mutations.test.ts index d4b7e300..63a39cee 100644 --- a/src/tui/components/color-menu/__tests__/mutations.test.ts +++ b/src/tui/components/color-menu/__tests__/mutations.test.ts @@ -9,6 +9,7 @@ import { clearAllWidgetStyling, cycleWidgetColor, cycleWidgetDim, + cycleWidgetNumberStyle, resetWidgetStyling, toggleWidgetBold, updateWidgetById @@ -58,7 +59,37 @@ describe('color-menu mutations', () => { expect(whole[1]?.dim).toBeUndefined(); }); - it('resetWidgetStyling removes color, backgroundColor, bold, and dim from one widget', () => { + it('cycleWidgetNumberStyle cycles default, compact, whole, then default for the selected widget only', () => { + const widgets: WidgetItem[] = [ + { id: '1', type: 'tokens-input' }, + { id: '2', type: 'tokens-output' } + ]; + + const compact = cycleWidgetNumberStyle(widgets, '1'); + const whole = cycleWidgetNumberStyle(compact, '1'); + const off = cycleWidgetNumberStyle(whole, '1'); + + expect(compact[0]?.numberFormat).toEqual({ style: 'compact' }); + expect(whole[0]?.numberFormat).toEqual({ style: 'whole' }); + expect(off[0]).toEqual({ id: '1', type: 'tokens-input' }); + expect(compact[1]?.numberFormat).toBeUndefined(); + }); + + it('cycleWidgetNumberStyle preserves an explicit decimals across the cycle', () => { + const widgets: WidgetItem[] = [ + { id: '1', type: 'tokens-input', numberFormat: { decimals: 2 } } + ]; + + const compact = cycleWidgetNumberStyle(widgets, '1'); + const whole = cycleWidgetNumberStyle(compact, '1'); + const off = cycleWidgetNumberStyle(whole, '1'); + + expect(compact[0]?.numberFormat).toEqual({ style: 'compact', decimals: 2 }); + expect(whole[0]?.numberFormat).toEqual({ style: 'whole', decimals: 2 }); + expect(off[0]?.numberFormat).toEqual({ decimals: 2 }); + }); + + it('resetWidgetStyling removes color, backgroundColor, bold, dim, and numberFormat from one widget', () => { const widgets: WidgetItem[] = [ { id: '1', @@ -66,7 +97,8 @@ describe('color-menu mutations', () => { color: 'red', backgroundColor: 'blue', bold: true, - dim: 'parens' + dim: 'parens', + numberFormat: { style: 'compact' } }, { id: '2', type: 'tokens-output', color: 'white', bold: true } ]; @@ -87,7 +119,7 @@ describe('color-menu mutations', () => { bold: true, dim: true }, - { id: '2', type: 'tokens-output', color: 'white', bold: true, dim: 'parens' } + { id: '2', type: 'tokens-output', color: 'white', bold: true, dim: 'parens', numberFormat: { style: 'whole' } } ]; const updated = clearAllWidgetStyling(widgets); diff --git a/src/tui/components/color-menu/mutations.ts b/src/tui/components/color-menu/mutations.ts index 855b7976..b5509b6c 100644 --- a/src/tui/components/color-menu/mutations.ts +++ b/src/tui/components/color-menu/mutations.ts @@ -1,3 +1,7 @@ +import type { + NumberFormat, + NumberStyle +} from '../../../types/NumberFormat'; import type { WidgetItem } from '../../../types/Widget'; import { getWidget } from '../../../utils/widgets'; @@ -60,6 +64,38 @@ export function cycleWidgetDim(widgets: WidgetItem[], widgetId: string): WidgetI }); } +export function cycleWidgetNumberStyle(widgets: WidgetItem[], widgetId: string): WidgetItem[] { + return updateWidgetById(widgets, widgetId, (widget) => { + // Cycle the number style: default (precise) -> compact -> whole -> default. + // Any explicit `decimals` is preserved across the cycle. + const currentStyle = widget.numberFormat?.style; + const nextStyle: NumberStyle | undefined = currentStyle === undefined + ? 'compact' + : currentStyle === 'compact' + ? 'whole' + : undefined; + const decimals = widget.numberFormat?.decimals; + + if (nextStyle === undefined && decimals === undefined) { + const { numberFormat, ...restWidget } = widget; + void numberFormat; // Intentionally unused + return restWidget; + } + + const nextFormat: NumberFormat = {}; + if (nextStyle !== undefined) { + nextFormat.style = nextStyle; + } + if (decimals !== undefined) { + nextFormat.decimals = decimals; + } + return { + ...widget, + numberFormat: nextFormat + }; + }); +} + export function resetWidgetStyling(widgets: WidgetItem[], widgetId: string): WidgetItem[] { return updateWidgetById(widgets, widgetId, (widget) => { const { @@ -67,12 +103,14 @@ export function resetWidgetStyling(widgets: WidgetItem[], widgetId: string): Wid backgroundColor, bold, dim, + numberFormat, ...restWidget } = widget; void color; // Intentionally unused void backgroundColor; // Intentionally unused void bold; // Intentionally unused void dim; // Intentionally unused + void numberFormat; // Intentionally unused return restWidget; }); } @@ -84,12 +122,14 @@ export function clearAllWidgetStyling(widgets: WidgetItem[]): WidgetItem[] { backgroundColor, bold, dim, + numberFormat, ...restWidget } = widget; void color; // Intentionally unused void backgroundColor; // Intentionally unused void bold; // Intentionally unused void dim; // Intentionally unused + void numberFormat; // Intentionally unused return restWidget; }); } diff --git a/src/types/NumberFormat.ts b/src/types/NumberFormat.ts new file mode 100644 index 00000000..a6cfe99d --- /dev/null +++ b/src/types/NumberFormat.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; + +// Decimal-rendering styles for numeric widgets: +// precise - fixed decimal places, trailing zeros kept ("1.0M"); today's default +// compact - trailing zeros trimmed, real fractions kept ("1M", "1.1M") +// whole - no decimals ("1M") +export const NUMBER_STYLES = ['precise', 'compact', 'whole'] as const; +export type NumberStyle = (typeof NUMBER_STYLES)[number]; + +// The kind of number a widget renders. Each kind keeps its own baseline +// precision in its formatter, so a token-oriented change never drags money off +// its 2-decimal convention. +export const NUMBER_KINDS = ['token', 'speed', 'percent', 'memory', 'cost'] as const; +export type NumberKind = (typeof NUMBER_KINDS)[number]; + +// A precision override. Both fields optional; an empty format means "use the +// formatter's built-in baseline", i.e. current output. +export const NumberFormatSchema = z.object({ + style: z.enum(NUMBER_STYLES).optional(), + decimals: z.number().int().min(0).max(6).optional() +}); +export type NumberFormat = z.infer; + +// Optional global precision, keyed by number kind. A kind set here wins over any +// per-widget value (same precedence as overrideForegroundColor / globalBold). +export const GlobalNumberFormatSchema = z.object({ + token: NumberFormatSchema.optional(), + speed: NumberFormatSchema.optional(), + percent: NumberFormatSchema.optional(), + memory: NumberFormatSchema.optional(), + cost: NumberFormatSchema.optional() +}); +export type GlobalNumberFormat = z.infer; diff --git a/src/types/Settings.ts b/src/types/Settings.ts index a596dbcf..d90696e2 100644 --- a/src/types/Settings.ts +++ b/src/types/Settings.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { ColorLevelSchema } from './ColorLevel'; import { FlexModeSchema } from './FlexMode'; +import { GlobalNumberFormatSchema } from './NumberFormat'; import { PowerlineConfigSchema } from './PowerlineConfig'; import { WidgetItemSchema } from './Widget'; @@ -73,6 +74,7 @@ export const SettingsSchema = z.object({ overrideBackgroundColor: z.string().optional(), overrideForegroundColor: z.string().optional(), globalBold: z.boolean().default(false), + numberFormat: GlobalNumberFormatSchema.optional(), gitCacheTtlSeconds: z.number().min(0).max(60).default(5), minimalistMode: z.boolean().default(false), powerline: PowerlineConfigSchema.default({ diff --git a/src/types/Widget.ts b/src/types/Widget.ts index ad2bf571..198f7379 100644 --- a/src/types/Widget.ts +++ b/src/types/Widget.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; +import { NumberFormatSchema } from './NumberFormat'; import type { RenderContext } from './RenderContext'; import type { Settings } from './Settings'; @@ -11,6 +12,7 @@ export const WidgetItemSchema = z.object({ backgroundColor: z.string().optional(), bold: z.boolean().optional(), dim: z.union([z.boolean(), z.literal('parens')]).optional(), + numberFormat: NumberFormatSchema.optional(), character: z.string().optional(), rawValue: z.boolean().optional(), customText: z.string().optional(), diff --git a/src/utils/__tests__/number-format.test.ts b/src/utils/__tests__/number-format.test.ts new file mode 100644 index 00000000..f7281f60 --- /dev/null +++ b/src/utils/__tests__/number-format.test.ts @@ -0,0 +1,128 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import type { + GlobalNumberFormat, + NumberFormat +} from '../../types/NumberFormat'; +import { DEFAULT_SETTINGS } from '../../types/Settings'; +import type { WidgetItem } from '../../types/Widget'; +import { formatTokens } from '../format-tokens'; +import { + formatCost, + formatPercent, + renderMagnitude, + resolveNumberFormat +} from '../number-format'; +import { formatSpeed } from '../speed-metrics'; + +describe('renderMagnitude', () => { + const cases: { value: number; format: NumberFormat; baseline: number; expected: string }[] = [ + { value: 1, format: {}, baseline: 1, expected: '1.0' }, + { value: 1, format: { style: 'compact' }, baseline: 1, expected: '1' }, + { value: 1.1, format: { style: 'compact' }, baseline: 1, expected: '1.1' }, + { value: 512, format: { style: 'compact' }, baseline: 1, expected: '512' }, + { value: 1, format: { decimals: 2 }, baseline: 1, expected: '1.00' }, + { value: 1.149, format: { style: 'whole' }, baseline: 1, expected: '1' }, + { value: 12, format: {}, baseline: 0, expected: '12' }, + { value: 12, format: { decimals: 1 }, baseline: 0, expected: '12.0' } + ]; + + it.each(cases)('value $value with $format over baseline $baseline -> $expected', ({ value, format, baseline, expected }) => { + expect(renderMagnitude(value, format, baseline)).toBe(expected); + }); +}); + +describe('formatPercent', () => { + it('keeps one decimal by default (unchanged)', () => { + expect(formatPercent(84.5)).toBe('84.5%'); + expect(formatPercent(100)).toBe('100.0%'); + }); + + it('compact trims a pointless trailing zero', () => { + expect(formatPercent(100, { style: 'compact' })).toBe('100%'); + expect(formatPercent(84.5, { style: 'compact' })).toBe('84.5%'); + }); + + it('whole rounds to an integer', () => { + expect(formatPercent(84.4, { style: 'whole' })).toBe('84%'); + expect(formatPercent(99.9, { style: 'whole' })).toBe('100%'); + }); +}); + +describe('formatCost', () => { + it('keeps two decimals by default (money, unchanged)', () => { + expect(formatCost(1.2)).toBe('$1.20'); + expect(formatCost(2.45)).toBe('$2.45'); + }); + + it('honors an explicit override', () => { + expect(formatCost(1.2, { style: 'compact' })).toBe('$1.2'); + expect(formatCost(1, { style: 'whole' })).toBe('$1'); + }); +}); + +describe('resolveNumberFormat', () => { + const widget = (numberFormat?: NumberFormat): WidgetItem => ({ + id: 'w', + type: 'tokens-input', + ...(numberFormat ? { numberFormat } : {}) + }); + const withGlobal = (numberFormat: GlobalNumberFormat) => ({ ...DEFAULT_SETTINGS, numberFormat }); + + it('returns an empty format when nothing is set (current behavior)', () => { + expect(resolveNumberFormat('token', widget(), DEFAULT_SETTINGS)).toEqual({}); + }); + + it('uses the per-widget value when no global is set', () => { + expect(resolveNumberFormat('token', widget({ style: 'compact' }), DEFAULT_SETTINGS)).toEqual({ style: 'compact' }); + }); + + it('lets a global for the kind win over the per-widget value', () => { + const settings = withGlobal({ token: { style: 'whole' } }); + expect(resolveNumberFormat('token', widget({ style: 'compact' }), settings)).toEqual({ style: 'whole' }); + }); + + it('ignores a global set for a different kind', () => { + const settings = withGlobal({ cost: { style: 'whole' } }); + expect(resolveNumberFormat('token', widget({ style: 'compact' }), settings)).toEqual({ style: 'compact' }); + }); +}); + +describe('formatTokens with a format', () => { + it('compact trims pointless trailing zeros', () => { + expect(formatTokens(1000000, { style: 'compact' })).toBe('1M'); + expect(formatTokens(1147000, { style: 'compact' })).toBe('1.1M'); + expect(formatTokens(512000, { style: 'compact' })).toBe('512k'); + }); + + it('whole drops decimals and still promotes to M correctly', () => { + expect(formatTokens(1000000, { style: 'whole' })).toBe('1M'); + expect(formatTokens(999600, { style: 'whole' })).toBe('1M'); + }); + + it('decimals widens precision', () => { + expect(formatTokens(1000000, { decimals: 2 })).toBe('1.00M'); + }); + + it('default (no format) is unchanged', () => { + expect(formatTokens(1000000)).toBe('1.0M'); + expect(formatTokens(512000)).toBe('512.0k'); + }); +}); + +describe('formatSpeed with a format', () => { + it('compact trims trailing zeros', () => { + expect(formatSpeed(1000, { style: 'compact' })).toBe('1k t/s'); + expect(formatSpeed(50, { style: 'compact' })).toBe('50 t/s'); + }); + + it('default is unchanged', () => { + expect(formatSpeed(1000)).toBe('1.0k t/s'); + expect(formatSpeed(50)).toBe('50.0 t/s'); + expect(formatSpeed(null)).toBe('—'); + }); +}); diff --git a/src/utils/__tests__/renderer-format-tokens.test.ts b/src/utils/__tests__/renderer-format-tokens.test.ts index a929e5fa..6f073feb 100644 --- a/src/utils/__tests__/renderer-format-tokens.test.ts +++ b/src/utils/__tests__/renderer-format-tokens.test.ts @@ -35,9 +35,9 @@ describe('formatTokens', () => { }); it('uses whole-number k and rolls up to M at decimals=0', () => { - expect(formatTokens(711000, 0)).toBe('711k'); - expect(formatTokens(999499, 0)).toBe('999k'); - expect(formatTokens(999500, 0)).toBe('1.0M'); - expect(formatTokens(1000000, 0)).toBe('1.0M'); + expect(formatTokens(711000, {}, 0)).toBe('711k'); + expect(formatTokens(999499, {}, 0)).toBe('999k'); + expect(formatTokens(999500, {}, 0)).toBe('1.0M'); + expect(formatTokens(1000000, {}, 0)).toBe('1.0M'); }); }); diff --git a/src/utils/format-tokens.ts b/src/utils/format-tokens.ts index d68ec0a4..66558ba0 100644 --- a/src/utils/format-tokens.ts +++ b/src/utils/format-tokens.ts @@ -1,12 +1,21 @@ -// Format a token count with `decimals` places in the "k" range. Once the k -// value would round up to "1000" at that precision (within half a displayed -// unit of 1M), promote to "1.0M" instead — at decimals=1 that boundary is -// 999_950 ("1000.0k" -> "1.0M"), at decimals=0 it is 999_500 ("1000k" -> "1.0M"). -// decimals defaults to 1; callers wanting a compact whole-number k pass 0. -export function formatTokens(count: number, decimals = 1): string { - if (count >= 1000000 - 500 / 10 ** decimals) - return `${(count / 1000000).toFixed(1)}M`; +import type { NumberFormat } from '../types/NumberFormat'; + +import { + effectiveDecimals, + renderMagnitude +} from './number-format'; + +// Format a token count, applying the optional `format` (style/decimals) on top +// of the "k"-range `decimals` baseline. Once the k value would round up to +// "1000" at the effective precision (within half a displayed unit of 1M), +// promote to the "M" range — at 1 decimal that boundary is 999_950, at 0 it is +// 999_500. `decimals` defaults to 1; callers wanting compact whole-number k pass +// 0. With no `format`, output is unchanged ("1.0M", "512.0k"). +export function formatTokens(count: number, format: NumberFormat = {}, decimals = 1): string { + const kDecimals = effectiveDecimals(format, decimals); + if (count >= 1000000 - 500 / 10 ** kDecimals) + return `${renderMagnitude(count / 1000000, format, 1)}M`; if (count >= 1000) - return `${(count / 1000).toFixed(decimals)}k`; + return `${renderMagnitude(count / 1000, format, decimals)}k`; return count.toString(); } diff --git a/src/utils/number-format.ts b/src/utils/number-format.ts new file mode 100644 index 00000000..4aa0967c --- /dev/null +++ b/src/utils/number-format.ts @@ -0,0 +1,47 @@ +import type { + NumberFormat, + NumberKind +} from '../types/NumberFormat'; +import type { Settings } from '../types/Settings'; +import type { WidgetItem } from '../types/Widget'; + +// Strip a pointless trailing ".0" (or ".00", ...) so a whole value reads cleanly +// while a meaningful fraction is left intact. +// "512.0" -> "512" "1.0" -> "1" "5.2" -> "5.2" "711" -> "711" +function trimTrailingZeros(value: string): string { + return value.includes('.') ? value.replace(/\.?0+$/, '') : value; +} + +// Decimal count a format resolves to over a tier's baseline precision. +export function effectiveDecimals(format: NumberFormat, baselineDecimals: number): number { + if (format.style === 'whole') + return 0; + return format.decimals ?? baselineDecimals; +} + +// Render a magnitude value (already divided into its k/M/G unit, or a bare +// percentage/cost) to its numeric string under `format`. `baselineDecimals` is +// the caller's own default precision, used when the format leaves it unset. An +// empty format reproduces the caller's current output. +export function renderMagnitude(value: number, format: NumberFormat, baselineDecimals: number): string { + const fixed = value.toFixed(effectiveDecimals(format, baselineDecimals)); + return format.style === 'compact' ? trimTrailingZeros(fixed) : fixed; +} + +// Render a percentage. `baselineDecimals` defaults to 1 ("84.5%"); callers that +// want a whole-percent baseline (e.g. a context bar) pass 0. +export function formatPercent(value: number, format: NumberFormat = {}, baselineDecimals = 1): string { + return `${renderMagnitude(value, format, baselineDecimals)}%`; +} + +// Render a USD cost (baseline 2 decimals: "$1.20"). +export function formatCost(value: number, format: NumberFormat = {}): string { + return `$${renderMagnitude(value, format, 2)}`; +} + +// Resolve the effective format for a widget of the given kind. A global entry for +// the kind wins outright; otherwise the widget's own value; otherwise an empty +// format (formatter baseline = current output). +export function resolveNumberFormat(kind: NumberKind, item: WidgetItem, settings: Settings): NumberFormat { + return settings.numberFormat?.[kind] ?? item.numberFormat ?? {}; +} diff --git a/src/utils/speed-metrics.ts b/src/utils/speed-metrics.ts index d08818ca..b22707de 100644 --- a/src/utils/speed-metrics.ts +++ b/src/utils/speed-metrics.ts @@ -1,5 +1,8 @@ +import type { NumberFormat } from '../types/NumberFormat'; import type { SpeedMetrics } from '../types/SpeedMetrics'; +import { renderMagnitude } from './number-format'; + /** * Calculates output tokens per second from speed metrics. * @@ -46,17 +49,17 @@ export function calculateTotalSpeed(metrics: SpeedMetrics): number | null { * Formats a tokens per second value for display. * * @param tokensPerSec Tokens per second value, or null if unavailable + * @param format Optional precision override (style/decimals); empty = unchanged * @returns Formatted string (e.g., "42.5 t/s", "1.2k t/s", or "—" for null) */ -export function formatSpeed(tokensPerSec: number | null): string { +export function formatSpeed(tokensPerSec: number | null, format: NumberFormat = {}): string { if (tokensPerSec === null) { return '—'; } if (tokensPerSec >= 1000) { - const kValue = tokensPerSec / 1000; - return `${kValue.toFixed(1)}k t/s`; + return `${renderMagnitude(tokensPerSec / 1000, format, 1)}k t/s`; } - return `${tokensPerSec.toFixed(1)} t/s`; + return `${renderMagnitude(tokensPerSec, format, 1)} t/s`; } diff --git a/src/widgets/BlockResetTimer.ts b/src/widgets/BlockResetTimer.ts index 6972e690..0518cae7 100644 --- a/src/widgets/BlockResetTimer.ts +++ b/src/widgets/BlockResetTimer.ts @@ -9,6 +9,10 @@ import type { WidgetEditorProps, WidgetItem } from '../types/Widget'; +import { + formatPercent, + resolveNumberFormat +} from '../utils/number-format'; import { formatUsageDuration, formatUsageResetAt, @@ -98,6 +102,7 @@ export class BlockResetTimerWidget implements Widget { const inverted = isUsageInverted(item); const compact = isUsageCompact(item); const dateMode = isUsageDateMode(item); + const format = resolveNumberFormat('percent', item, settings); if (context.isPreview) { const previewPercent = inverted ? 90.0 : 10.0; @@ -105,13 +110,13 @@ export class BlockResetTimerWidget implements Widget { if (isUsageProgressMode(displayMode)) { const barWidth = getUsageProgressBarWidth(displayMode); const progressBar = makeTimerProgressBar(previewPercent, barWidth); - return formatRawOrLabeledValue(item, 'Reset ', `[${progressBar}] ${previewPercent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, 'Reset ', `[${progressBar}] ${formatPercent(previewPercent, format)}`); } if (isUsageSliderMode(displayMode)) { const slider = makeSliderBar(previewPercent); const sliderDisplay = displayMode === 'slider' - ? `${slider} ${previewPercent.toFixed(1)}%` + ? `${slider} ${formatPercent(previewPercent, format)}` : slider; return formatRawOrLabeledValue(item, 'Reset ', sliderDisplay); } @@ -145,15 +150,14 @@ export class BlockResetTimerWidget implements Widget { const barWidth = getUsageProgressBarWidth(displayMode); const percent = inverted ? window.remainingPercent : window.elapsedPercent; const progressBar = makeTimerProgressBar(percent, barWidth); - const percentage = percent.toFixed(1); - return formatRawOrLabeledValue(item, 'Reset ', `[${progressBar}] ${percentage}%`); + return formatRawOrLabeledValue(item, 'Reset ', `[${progressBar}] ${formatPercent(percent, format)}`); } if (isUsageSliderMode(displayMode)) { const percent = inverted ? window.remainingPercent : window.elapsedPercent; const slider = makeSliderBar(percent); const sliderDisplay = displayMode === 'slider' - ? `${slider} ${percent.toFixed(1)}%` + ? `${slider} ${formatPercent(percent, format)}` : slider; return formatRawOrLabeledValue(item, 'Reset ', sliderDisplay); } diff --git a/src/widgets/BlockTimer.ts b/src/widgets/BlockTimer.ts index 41a0ef45..738171f4 100644 --- a/src/widgets/BlockTimer.ts +++ b/src/widgets/BlockTimer.ts @@ -6,6 +6,10 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { + formatPercent, + resolveNumberFormat +} from '../utils/number-format'; import { formatUsageDuration, resolveUsageWindowWithFallback @@ -67,6 +71,7 @@ export class BlockTimerWidget implements Widget { const displayMode = getUsageDisplayMode(item); const inverted = isUsageInverted(item); const compact = isUsageCompact(item); + const format = resolveNumberFormat('percent', item, settings); if (context.isPreview) { const previewPercent = inverted ? 26.1 : 73.9; @@ -74,13 +79,13 @@ export class BlockTimerWidget implements Widget { if (isUsageProgressMode(displayMode)) { const barWidth = getUsageProgressBarWidth(displayMode); const progressBar = makeTimerProgressBar(previewPercent, barWidth); - return formatRawOrLabeledValue(item, 'Block ', `[${progressBar}] ${previewPercent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, 'Block ', `[${progressBar}] ${formatPercent(previewPercent, format)}`); } if (isUsageSliderMode(displayMode)) { const slider = makeSliderBar(previewPercent); const sliderDisplay = displayMode === 'slider' - ? `${slider} ${previewPercent.toFixed(1)}%` + ? `${slider} ${formatPercent(previewPercent, format)}` : slider; return formatRawOrLabeledValue(item, 'Block ', sliderDisplay); } @@ -113,15 +118,14 @@ export class BlockTimerWidget implements Widget { const barWidth = getUsageProgressBarWidth(displayMode); const percent = inverted ? window.remainingPercent : window.elapsedPercent; const progressBar = makeTimerProgressBar(percent, barWidth); - const percentage = percent.toFixed(1); - return formatRawOrLabeledValue(item, 'Block ', `[${progressBar}] ${percentage}%`); + return formatRawOrLabeledValue(item, 'Block ', `[${progressBar}] ${formatPercent(percent, format)}`); } if (isUsageSliderMode(displayMode)) { const percent = inverted ? window.remainingPercent : window.elapsedPercent; const slider = makeSliderBar(percent); const sliderDisplay = displayMode === 'slider' - ? `${slider} ${percent.toFixed(1)}%` + ? `${slider} ${formatPercent(percent, format)}` : slider; return formatRawOrLabeledValue(item, 'Block ', sliderDisplay); } diff --git a/src/widgets/CacheHitRate.ts b/src/widgets/CacheHitRate.ts index 4544bb87..900c5a3f 100644 --- a/src/widgets/CacheHitRate.ts +++ b/src/widgets/CacheHitRate.ts @@ -6,6 +6,10 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { + formatPercent, + resolveNumberFormat +} from '../utils/number-format'; import { getCacheHitRate, @@ -53,7 +57,8 @@ export class CacheHitRateWidget implements Widget { return null; } - return formatRawOrLabeledValue(item, 'Cache Hit: ', `${hitRate.toFixed(1)}%`); + const format = resolveNumberFormat('percent', item, settings); + return formatRawOrLabeledValue(item, 'Cache Hit: ', formatPercent(hitRate, format)); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { diff --git a/src/widgets/CacheRead.ts b/src/widgets/CacheRead.ts index b14b1afb..54b98166 100644 --- a/src/widgets/CacheRead.ts +++ b/src/widgets/CacheRead.ts @@ -6,6 +6,7 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { resolveNumberFormat } from '../utils/number-format'; import { formatTokensWithPercentage, @@ -49,7 +50,9 @@ export class CacheReadWidget implements Widget { return null; } - const value = formatTokensWithPercentage(tokens.read, getCacheReadPercentage(tokens)); + const tokenFormat = resolveNumberFormat('token', item, settings); + const percentFormat = resolveNumberFormat('percent', item, settings); + const value = formatTokensWithPercentage(tokens.read, getCacheReadPercentage(tokens), tokenFormat, percentFormat); return formatRawOrLabeledValue(item, 'Cache Read: ', value); } diff --git a/src/widgets/CacheWrite.ts b/src/widgets/CacheWrite.ts index 63807bbd..d9f8b663 100644 --- a/src/widgets/CacheWrite.ts +++ b/src/widgets/CacheWrite.ts @@ -6,6 +6,7 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { resolveNumberFormat } from '../utils/number-format'; import { formatTokensWithPercentage, @@ -49,7 +50,9 @@ export class CacheWriteWidget implements Widget { return null; } - const value = formatTokensWithPercentage(tokens.creation, getCacheWritePercentage(tokens)); + const tokenFormat = resolveNumberFormat('token', item, settings); + const percentFormat = resolveNumberFormat('percent', item, settings); + const value = formatTokensWithPercentage(tokens.creation, getCacheWritePercentage(tokens), tokenFormat, percentFormat); return formatRawOrLabeledValue(item, 'Cache Write: ', value); } diff --git a/src/widgets/CompactionCounter.ts b/src/widgets/CompactionCounter.ts index 7b2a0212..e95e3829 100644 --- a/src/widgets/CompactionCounter.ts +++ b/src/widgets/CompactionCounter.ts @@ -1,3 +1,4 @@ +import type { NumberFormat } from '../types/NumberFormat'; import type { CompactionData, RenderContext @@ -12,6 +13,7 @@ import type { } from '../types/Widget'; import { ZERO_COMPACTION_STATS } from '../utils/compaction'; import { formatTokens } from '../utils/format-tokens'; +import { resolveNumberFormat } from '../utils/number-format'; import { isMetadataFlagEnabled, @@ -122,12 +124,12 @@ function getMetricValue(data: CompactionData, metric: CompactionMetric): number } } -function formatReclaimedSuffix(tokensReclaimed: number, item: WidgetItem): string { +function formatReclaimedSuffix(tokensReclaimed: number, item: WidgetItem, format: NumberFormat): string { if (tokensReclaimed <= 0) { return ''; } const symbol = getSlotSymbol(item, RECLAIMED_SLOT); - return symbol.length > 0 ? ` ${symbol}${formatTokens(tokensReclaimed)}` : ` ${formatTokens(tokensReclaimed)}`; + return symbol.length > 0 ? ` ${symbol}${formatTokens(tokensReclaimed, format)}` : ` ${formatTokens(tokensReclaimed, format)}`; } function formatTriggerSuffix(byTrigger: CompactionData['byTrigger']): string { @@ -144,13 +146,13 @@ function formatTriggerSuffix(byTrigger: CompactionData['byTrigger']): string { return parts.length > 0 ? ` (${parts.join(', ')})` : ''; } -function formatStats(data: CompactionData, item: WidgetItem, icon: string): string { +function formatStats(data: CompactionData, item: WidgetItem, icon: string, format: NumberFormat): string { let out = formatCount(data.count, getFormat(item), icon); if (isMetadataFlagEnabled(item, SHOW_TRIGGERS_METADATA_KEY)) { out += formatTriggerSuffix(data.byTrigger); } if (isMetadataFlagEnabled(item, SHOW_RECLAIMED_METADATA_KEY)) { - out += formatReclaimedSuffix(data.tokensReclaimed, item); + out += formatReclaimedSuffix(data.tokensReclaimed, item, format); } return out; } @@ -243,7 +245,7 @@ export class CompactionCounterWidget implements Widget { } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { - void settings; + const format = resolveNumberFormat('token', item, settings); const data = context.isPreview ? SAMPLE_STATS : (context.compactionData ?? ZERO_COMPACTION_STATS); const metric = getMetric(item); @@ -252,7 +254,7 @@ export class CompactionCounterWidget implements Widget { if (value === 0 && isHideZeroEnabled(item) && !context.isPreview) { return null; } - return metric === 'reclaimed' ? formatTokens(value) : String(value); + return metric === 'reclaimed' ? formatTokens(value, format) : String(value); } if (data.count === 0 && isHideZeroEnabled(item) && !context.isPreview) { @@ -260,7 +262,7 @@ export class CompactionCounterWidget implements Widget { } const icon = isNerdFontEnabled(item, NERD_FONT_FORMATS) ? COMPACTION_NERD_FONT_ICON : COMPACTION_ICON; - return formatStats(data, item, icon); + return formatStats(data, item, icon, format); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { diff --git a/src/widgets/ContextBar.ts b/src/widgets/ContextBar.ts index 400bc54d..89a2454a 100644 --- a/src/widgets/ContextBar.ts +++ b/src/widgets/ContextBar.ts @@ -11,6 +11,10 @@ import { getContextConfig, getModelContextIdentifier } from '../utils/model-context'; +import { + formatPercent, + resolveNumberFormat +} from '../utils/number-format'; import { formatTokens } from '../utils/renderer'; import { makeUsageProgressBar } from '../utils/usage'; @@ -79,6 +83,8 @@ export class ContextBarWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { const displayMode = getDisplayMode(item); + const tokenFormat = resolveNumberFormat('token', item, settings); + const percentFormat = resolveNumberFormat('percent', item, settings); if (context.isPreview) { if (isBarSliderMode(displayMode)) { @@ -111,17 +117,18 @@ export class ContextBarWidget implements Widget { const percent = (used / total) * 100; const clampedPercent = Math.max(0, Math.min(100, percent)); - const usedDisplay = formatTokens(used, 0); - const totalDisplay = formatTokens(total, 0); + const usedDisplay = formatTokens(used, tokenFormat, 0); + const totalDisplay = formatTokens(total, tokenFormat, 0); + const percentDisplay = formatPercent(clampedPercent, percentFormat, 0); if (isBarSliderMode(displayMode)) { const slider = makeSliderBar(clampedPercent); - const sliderDisplay = displayMode === 'slider' ? `${slider} ${usedDisplay}/${totalDisplay} (${Math.round(clampedPercent)}%)` : slider; + const sliderDisplay = displayMode === 'slider' ? `${slider} ${usedDisplay}/${totalDisplay} (${percentDisplay})` : slider; return item.rawValue ? sliderDisplay : `Context: ${sliderDisplay}`; } const barWidth = displayMode === 'progress' ? 32 : 16; - const display = `${makeUsageProgressBar(clampedPercent, barWidth)} ${usedDisplay}/${totalDisplay} (${Math.round(clampedPercent)}%)`; + const display = `${makeUsageProgressBar(clampedPercent, barWidth)} ${usedDisplay}/${totalDisplay} (${percentDisplay})`; return item.rawValue ? display : `Context: ${display}`; } diff --git a/src/widgets/ContextLength.ts b/src/widgets/ContextLength.ts index 85c9c312..92575f71 100644 --- a/src/widgets/ContextLength.ts +++ b/src/widgets/ContextLength.ts @@ -6,6 +6,7 @@ import type { WidgetItem } from '../types/Widget'; import { getContextWindowContextLengthTokens } from '../utils/context-window'; +import { resolveNumberFormat } from '../utils/number-format'; import { formatTokens } from '../utils/renderer'; export class ContextLengthWidget implements Widget { @@ -18,17 +19,18 @@ export class ContextLengthWidget implements Widget { } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + const format = resolveNumberFormat('token', item, settings); if (context.isPreview) { return item.rawValue ? '18.6k' : 'Ctx: 18.6k'; } const contextLengthTokens = getContextWindowContextLengthTokens(context.data); if (contextLengthTokens !== null) { - return item.rawValue ? formatTokens(contextLengthTokens) : `Ctx: ${formatTokens(contextLengthTokens)}`; + return item.rawValue ? formatTokens(contextLengthTokens, format) : `Ctx: ${formatTokens(contextLengthTokens, format)}`; } if (context.tokenMetrics) { - return item.rawValue ? formatTokens(context.tokenMetrics.contextLength) : `Ctx: ${formatTokens(context.tokenMetrics.contextLength)}`; + return item.rawValue ? formatTokens(context.tokenMetrics.contextLength, format) : `Ctx: ${formatTokens(context.tokenMetrics.contextLength, format)}`; } return null; } diff --git a/src/widgets/ContextPercentage.ts b/src/widgets/ContextPercentage.ts index f6940106..69aff8b3 100644 --- a/src/widgets/ContextPercentage.ts +++ b/src/widgets/ContextPercentage.ts @@ -7,6 +7,10 @@ import type { WidgetItem } from '../types/Widget'; import { calculateContextPercentageMetrics } from '../utils/context-percentage'; +import { + formatPercent, + resolveNumberFormat +} from '../utils/number-format'; import { getContextInverseModifierText, @@ -50,10 +54,11 @@ export class ContextPercentageWidget implements Widget { const label = isInverse ? 'Ctx Left: ' : 'Ctx Used: '; const sliderMode = getContextSliderMode(item); const contextPercentageMetrics = calculateContextPercentageMetrics(context); + const format = resolveNumberFormat('percent', item, settings); const formatContextPercentage = (displayPercentage: number): string => { - const sliderResult = renderContextSlider(sliderMode, displayPercentage); - return formatRawOrLabeledValue(item, label, sliderResult ?? `${displayPercentage.toFixed(1)}%`); + const sliderResult = renderContextSlider(sliderMode, displayPercentage, format); + return formatRawOrLabeledValue(item, label, sliderResult ?? formatPercent(displayPercentage, format)); }; if (context.isPreview) { diff --git a/src/widgets/ContextPercentageUsable.ts b/src/widgets/ContextPercentageUsable.ts index 9dd8c679..e5dc14e0 100644 --- a/src/widgets/ContextPercentageUsable.ts +++ b/src/widgets/ContextPercentageUsable.ts @@ -11,6 +11,10 @@ import { getContextConfig, getModelContextIdentifier } from '../utils/model-context'; +import { + formatPercent, + resolveNumberFormat +} from '../utils/number-format'; import { getContextInverseModifierText, @@ -56,10 +60,11 @@ export class ContextPercentageUsableWidget implements Widget { const modelIdentifier = getModelContextIdentifier(context.data?.model); const contextWindowMetrics = getContextWindowMetrics(context.data); const contextConfig = getContextConfig(modelIdentifier, contextWindowMetrics.windowSize); + const format = resolveNumberFormat('percent', item, settings); const formatContextPercentage = (displayPercentage: number): string => { - const sliderResult = renderContextSlider(sliderMode, displayPercentage); - return formatRawOrLabeledValue(item, label, sliderResult ?? `${displayPercentage.toFixed(1)}%`); + const sliderResult = renderContextSlider(sliderMode, displayPercentage, format); + return formatRawOrLabeledValue(item, label, sliderResult ?? formatPercent(displayPercentage, format)); }; if (context.isPreview) { diff --git a/src/widgets/ContextWindow.ts b/src/widgets/ContextWindow.ts index 3c2b102f..51d12e9f 100644 --- a/src/widgets/ContextWindow.ts +++ b/src/widgets/ContextWindow.ts @@ -10,6 +10,7 @@ import { getContextConfig, getModelContextIdentifier } from '../utils/model-context'; +import { resolveNumberFormat } from '../utils/number-format'; import { formatTokens } from '../utils/renderer'; export class ContextWindowWidget implements Widget { @@ -22,6 +23,7 @@ export class ContextWindowWidget implements Widget { } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + const format = resolveNumberFormat('token', item, settings); if (context.isPreview) { return item.rawValue ? '200k' : 'Win: 200k'; } @@ -37,7 +39,7 @@ export class ContextWindowWidget implements Widget { return null; } - return item.rawValue ? formatTokens(total) : `Win: ${formatTokens(total)}`; + return item.rawValue ? formatTokens(total, format) : `Win: ${formatTokens(total, format)}`; } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/ExtraUsageUtilization.ts b/src/widgets/ExtraUsageUtilization.ts index 63d8348c..cbb8bbb1 100644 --- a/src/widgets/ExtraUsageUtilization.ts +++ b/src/widgets/ExtraUsageUtilization.ts @@ -6,6 +6,10 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { + formatPercent, + resolveNumberFormat +} from '../utils/number-format'; import { getUsageErrorMessage } from '../utils/usage'; import { @@ -65,6 +69,7 @@ export class ExtraUsageUtilizationWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { const displayMode = getUsageDisplayMode(item); const inverted = isUsageInverted(item); + const format = resolveNumberFormat('percent', item, settings); if (context.isPreview) { const previewPercent = 2.6; @@ -73,16 +78,16 @@ export class ExtraUsageUtilizationWidget implements Widget { if (isUsageProgressMode(displayMode)) { const width = getUsageProgressBarWidth(displayMode); const progressBar = makeTimerProgressBar(renderedPercent, width); - return formatRawOrLabeledValue(item, 'Overage: ', `[${progressBar}] ${renderedPercent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, 'Overage: ', `[${progressBar}] ${formatPercent(renderedPercent, format)}`); } if (isUsageSliderMode(displayMode)) { const slider = makeSliderBar(renderedPercent); - const sliderDisplay = displayMode === 'slider' ? `${slider} ${renderedPercent.toFixed(1)}%` : slider; + const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(renderedPercent, format)}` : slider; return formatRawOrLabeledValue(item, 'Overage: ', sliderDisplay); } - return formatRawOrLabeledValue(item, 'Overage: ', `${renderedPercent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, 'Overage: ', formatPercent(renderedPercent, format)); } const data = context.usageData ?? {}; @@ -104,16 +109,16 @@ export class ExtraUsageUtilizationWidget implements Widget { if (isUsageProgressMode(displayMode)) { const width = getUsageProgressBarWidth(displayMode); const progressBar = makeTimerProgressBar(renderedPercent, width); - return formatRawOrLabeledValue(item, 'Overage: ', `[${progressBar}] ${renderedPercent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, 'Overage: ', `[${progressBar}] ${formatPercent(renderedPercent, format)}`); } if (isUsageSliderMode(displayMode)) { const slider = makeSliderBar(renderedPercent); - const sliderDisplay = displayMode === 'slider' ? `${slider} ${renderedPercent.toFixed(1)}%` : slider; + const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(renderedPercent, format)}` : slider; return formatRawOrLabeledValue(item, 'Overage: ', sliderDisplay); } - return formatRawOrLabeledValue(item, 'Overage: ', `${renderedPercent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, 'Overage: ', formatPercent(renderedPercent, format)); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { diff --git a/src/widgets/FreeMemory.ts b/src/widgets/FreeMemory.ts index 4e82d7c2..2d865f80 100644 --- a/src/widgets/FreeMemory.ts +++ b/src/widgets/FreeMemory.ts @@ -1,6 +1,7 @@ import { execSync } from 'child_process'; import os from 'os'; +import type { NumberFormat } from '../types/NumberFormat'; import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { @@ -8,18 +9,22 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { + renderMagnitude, + resolveNumberFormat +} from '../utils/number-format'; -function formatBytes(bytes: number): string { +function formatBytes(bytes: number, format: NumberFormat): string { const GB = 1024 ** 3; const MB = 1024 ** 2; const KB = 1024; if (bytes >= GB) - return `${(bytes / GB).toFixed(1)}G`; + return `${renderMagnitude(bytes / GB, format, 1)}G`; if (bytes >= MB) - return `${(bytes / MB).toFixed(0)}M`; + return `${renderMagnitude(bytes / MB, format, 0)}M`; if (bytes >= KB) - return `${(bytes / KB).toFixed(0)}K`; + return `${renderMagnitude(bytes / KB, format, 0)}K`; return `${bytes}B`; } @@ -71,6 +76,7 @@ export class FreeMemoryWidget implements Widget { } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + const format = resolveNumberFormat('memory', item, settings); if (context.isPreview) { return item.rawValue ? '12.4G/16.0G' : 'Mem: 12.4G/16.0G'; } @@ -86,7 +92,7 @@ export class FreeMemoryWidget implements Widget { used = total - os.freemem(); } - const value = `${formatBytes(used)}/${formatBytes(total)}`; + const value = `${formatBytes(used, format)}/${formatBytes(total, format)}`; return item.rawValue ? value : `Mem: ${value}`; } diff --git a/src/widgets/InputSpeed.ts b/src/widgets/InputSpeed.ts index f0f37f6a..70321586 100644 --- a/src/widgets/InputSpeed.ts +++ b/src/widgets/InputSpeed.ts @@ -27,8 +27,7 @@ export class InputSpeedWidget implements Widget { } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { - void settings; - return renderSpeedWidgetValue('input', item, context); + return renderSpeedWidgetValue('input', item, context, settings); } getCustomKeybinds(): CustomKeybind[] { diff --git a/src/widgets/OutputSpeed.ts b/src/widgets/OutputSpeed.ts index 66aa8ef0..43a30a9c 100644 --- a/src/widgets/OutputSpeed.ts +++ b/src/widgets/OutputSpeed.ts @@ -27,8 +27,7 @@ export class OutputSpeedWidget implements Widget { } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { - void settings; - return renderSpeedWidgetValue('output', item, context); + return renderSpeedWidgetValue('output', item, context, settings); } getCustomKeybinds(): CustomKeybind[] { diff --git a/src/widgets/SessionCost.ts b/src/widgets/SessionCost.ts index e3dcdfed..684c8054 100644 --- a/src/widgets/SessionCost.ts +++ b/src/widgets/SessionCost.ts @@ -5,6 +5,10 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { + formatCost, + resolveNumberFormat +} from '../utils/number-format'; export class SessionCostWidget implements Widget { getDefaultColor(): string { return 'green'; } @@ -16,6 +20,7 @@ export class SessionCostWidget implements Widget { } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + const format = resolveNumberFormat('cost', item, settings); if (context.isPreview) { return item.rawValue ? '$2.45' : 'Cost: $2.45'; } @@ -25,8 +30,7 @@ export class SessionCostWidget implements Widget { return null; } - // Format the cost to 2 decimal places - const formattedCost = `$${totalCost.toFixed(2)}`; + const formattedCost = formatCost(totalCost, format); return item.rawValue ? formattedCost : `Cost: ${formattedCost}`; } diff --git a/src/widgets/SessionUsage.ts b/src/widgets/SessionUsage.ts index 2c9710db..06418ae5 100644 --- a/src/widgets/SessionUsage.ts +++ b/src/widgets/SessionUsage.ts @@ -6,6 +6,10 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { + formatPercent, + resolveNumberFormat +} from '../utils/number-format'; import { getUsageErrorMessage, resolveUsageWindowWithFallback @@ -61,6 +65,7 @@ export class SessionUsageWidget implements Widget { const displayMode = getUsageDisplayMode(item); const inverted = isUsageInverted(item); const showCursor = isUsageCursorEnabled(item); + const format = resolveNumberFormat('percent', item, settings); if (context.isPreview) { const previewPercent = 20; @@ -69,17 +74,17 @@ export class SessionUsageWidget implements Widget { if (isUsageProgressMode(displayMode)) { const width = getUsageProgressBarWidth(displayMode); const progressBar = makeTimerProgressBar(renderedPercent, width, showCursor ? { cursorPercent: 50 } : undefined); - const progressDisplay = `[${progressBar}] ${renderedPercent.toFixed(1)}%`; + const progressDisplay = `[${progressBar}] ${formatPercent(renderedPercent, format)}`; return formatRawOrLabeledValue(item, 'Session: ', progressDisplay); } if (isUsageSliderMode(displayMode)) { const slider = makeSliderBar(renderedPercent, undefined, showCursor ? { cursorPercent: 50 } : undefined); - const sliderDisplay = displayMode === 'slider' ? `${slider} ${renderedPercent.toFixed(1)}%` : slider; + const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(renderedPercent, format)}` : slider; return formatRawOrLabeledValue(item, 'Session: ', sliderDisplay); } - return formatRawOrLabeledValue(item, 'Session: ', `${renderedPercent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, 'Session: ', formatPercent(renderedPercent, format)); } const data = context.usageData ?? {}; @@ -104,17 +109,17 @@ export class SessionUsageWidget implements Widget { const width = getUsageProgressBarWidth(displayMode); const progressBar = makeTimerProgressBar(renderedPercent, width, getCursorOptions()); - const progressDisplay = `[${progressBar}] ${renderedPercent.toFixed(1)}%`; + const progressDisplay = `[${progressBar}] ${formatPercent(renderedPercent, format)}`; return formatRawOrLabeledValue(item, 'Session: ', progressDisplay); } if (isUsageSliderMode(displayMode)) { const slider = makeSliderBar(renderedPercent, undefined, getCursorOptions()); - const sliderDisplay = displayMode === 'slider' ? `${slider} ${renderedPercent.toFixed(1)}%` : slider; + const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(renderedPercent, format)}` : slider; return formatRawOrLabeledValue(item, 'Session: ', sliderDisplay); } - return formatRawOrLabeledValue(item, 'Session: ', `${renderedPercent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, 'Session: ', formatPercent(renderedPercent, format)); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { diff --git a/src/widgets/TokensCached.ts b/src/widgets/TokensCached.ts index c1fd8bfb..37d62664 100644 --- a/src/widgets/TokensCached.ts +++ b/src/widgets/TokensCached.ts @@ -5,6 +5,7 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { resolveNumberFormat } from '../utils/number-format'; import { formatTokens } from '../utils/renderer'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; @@ -19,12 +20,13 @@ export class TokensCachedWidget implements Widget { } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + const format = resolveNumberFormat('token', item, settings); if (context.isPreview) { return formatRawOrLabeledValue(item, 'Cached: ', '12k'); } if (context.tokenMetrics) { - return formatRawOrLabeledValue(item, 'Cached: ', formatTokens(context.tokenMetrics.cachedTokens)); + return formatRawOrLabeledValue(item, 'Cached: ', formatTokens(context.tokenMetrics.cachedTokens, format)); } return null; } diff --git a/src/widgets/TokensInput.ts b/src/widgets/TokensInput.ts index dfebe6b4..6e672f09 100644 --- a/src/widgets/TokensInput.ts +++ b/src/widgets/TokensInput.ts @@ -6,6 +6,7 @@ import type { WidgetItem } from '../types/Widget'; import { getContextWindowInputTotalTokens } from '../utils/context-window'; +import { resolveNumberFormat } from '../utils/number-format'; import { formatTokens } from '../utils/renderer'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; @@ -20,17 +21,18 @@ export class TokensInputWidget implements Widget { } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + const format = resolveNumberFormat('token', item, settings); if (context.isPreview) { return formatRawOrLabeledValue(item, 'In: ', '15.2k'); } if (context.tokenMetrics) { - return formatRawOrLabeledValue(item, 'In: ', formatTokens(context.tokenMetrics.inputTokens)); + return formatRawOrLabeledValue(item, 'In: ', formatTokens(context.tokenMetrics.inputTokens, format)); } const inputTotalTokens = getContextWindowInputTotalTokens(context.data); if (inputTotalTokens !== null) { - return formatRawOrLabeledValue(item, 'In: ', formatTokens(inputTotalTokens)); + return formatRawOrLabeledValue(item, 'In: ', formatTokens(inputTotalTokens, format)); } return null; } diff --git a/src/widgets/TokensOutput.ts b/src/widgets/TokensOutput.ts index a9e297a9..92c0b30e 100644 --- a/src/widgets/TokensOutput.ts +++ b/src/widgets/TokensOutput.ts @@ -6,6 +6,7 @@ import type { WidgetItem } from '../types/Widget'; import { getContextWindowOutputTotalTokens } from '../utils/context-window'; +import { resolveNumberFormat } from '../utils/number-format'; import { formatTokens } from '../utils/renderer'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; @@ -20,17 +21,18 @@ export class TokensOutputWidget implements Widget { } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + const format = resolveNumberFormat('token', item, settings); if (context.isPreview) { return formatRawOrLabeledValue(item, 'Out: ', '3.4k'); } if (context.tokenMetrics) { - return formatRawOrLabeledValue(item, 'Out: ', formatTokens(context.tokenMetrics.outputTokens)); + return formatRawOrLabeledValue(item, 'Out: ', formatTokens(context.tokenMetrics.outputTokens, format)); } const outputTotalTokens = getContextWindowOutputTotalTokens(context.data); if (outputTotalTokens !== null) { - return formatRawOrLabeledValue(item, 'Out: ', formatTokens(outputTotalTokens)); + return formatRawOrLabeledValue(item, 'Out: ', formatTokens(outputTotalTokens, format)); } return null; } diff --git a/src/widgets/TokensTotal.ts b/src/widgets/TokensTotal.ts index 5f6e57bd..15aa9bb0 100644 --- a/src/widgets/TokensTotal.ts +++ b/src/widgets/TokensTotal.ts @@ -5,6 +5,7 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { resolveNumberFormat } from '../utils/number-format'; import { formatTokens } from '../utils/renderer'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; @@ -19,12 +20,13 @@ export class TokensTotalWidget implements Widget { } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + const format = resolveNumberFormat('token', item, settings); if (context.isPreview) { return formatRawOrLabeledValue(item, 'Total: ', '30.6k'); } if (context.tokenMetrics) { - return formatRawOrLabeledValue(item, 'Total: ', formatTokens(context.tokenMetrics.totalTokens)); + return formatRawOrLabeledValue(item, 'Total: ', formatTokens(context.tokenMetrics.totalTokens, format)); } return null; } diff --git a/src/widgets/TotalSpeed.ts b/src/widgets/TotalSpeed.ts index 000d9dc8..9fa950c5 100644 --- a/src/widgets/TotalSpeed.ts +++ b/src/widgets/TotalSpeed.ts @@ -27,8 +27,7 @@ export class TotalSpeedWidget implements Widget { } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { - void settings; - return renderSpeedWidgetValue('total', item, context); + return renderSpeedWidgetValue('total', item, context, settings); } getCustomKeybinds(): CustomKeybind[] { diff --git a/src/widgets/WeeklyOpusUsage.ts b/src/widgets/WeeklyOpusUsage.ts index 8d17d4be..b1f96035 100644 --- a/src/widgets/WeeklyOpusUsage.ts +++ b/src/widgets/WeeklyOpusUsage.ts @@ -6,6 +6,10 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { + formatPercent, + resolveNumberFormat +} from '../utils/number-format'; import { getUsageErrorMessage, resolveWeeklyOpusUsageWindow @@ -63,6 +67,7 @@ export class WeeklyOpusUsageWidget implements Widget { const displayMode = getUsageDisplayMode(item); const inverted = isUsageInverted(item); const showCursor = isUsageCursorEnabled(item); + const format = resolveNumberFormat('percent', item, settings); if (context.isPreview) { const previewPercent = 4; @@ -71,17 +76,17 @@ export class WeeklyOpusUsageWidget implements Widget { if (isUsageProgressMode(displayMode)) { const width = getUsageProgressBarWidth(displayMode); const progressBar = makeTimerProgressBar(renderedPercent, width, showCursor ? { cursorPercent: 50 } : undefined); - const progressDisplay = `[${progressBar}] ${renderedPercent.toFixed(1)}%`; + const progressDisplay = `[${progressBar}] ${formatPercent(renderedPercent, format)}`; 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; + const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(renderedPercent, format)}` : slider; return formatRawOrLabeledValue(item, LABEL, sliderDisplay); } - return formatRawOrLabeledValue(item, LABEL, `${renderedPercent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, LABEL, formatPercent(renderedPercent, format)); } const data = context.usageData ?? {}; @@ -106,17 +111,17 @@ export class WeeklyOpusUsageWidget implements Widget { const width = getUsageProgressBarWidth(displayMode); const progressBar = makeTimerProgressBar(renderedPercent, width, getCursorOptions()); - const progressDisplay = `[${progressBar}] ${renderedPercent.toFixed(1)}%`; + const progressDisplay = `[${progressBar}] ${formatPercent(renderedPercent, format)}`; return formatRawOrLabeledValue(item, LABEL, progressDisplay); } if (isUsageSliderMode(displayMode)) { const slider = makeSliderBar(renderedPercent, undefined, getCursorOptions()); - const sliderDisplay = displayMode === 'slider' ? `${slider} ${renderedPercent.toFixed(1)}%` : slider; + const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(renderedPercent, format)}` : slider; return formatRawOrLabeledValue(item, LABEL, sliderDisplay); } - return formatRawOrLabeledValue(item, LABEL, `${renderedPercent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, LABEL, formatPercent(renderedPercent, format)); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { diff --git a/src/widgets/WeeklyResetTimer.ts b/src/widgets/WeeklyResetTimer.ts index 4d2041f9..61e28938 100644 --- a/src/widgets/WeeklyResetTimer.ts +++ b/src/widgets/WeeklyResetTimer.ts @@ -9,6 +9,10 @@ import type { WidgetEditorProps, WidgetItem } from '../types/Widget'; +import { + formatPercent, + resolveNumberFormat +} from '../utils/number-format'; import { formatUsageDuration, formatUsageResetAt, @@ -177,6 +181,7 @@ export class WeeklyResetTimerWidget implements Widget { const compact = isUsageCompact(item); const dateMode = isUsageDateMode(item); const useDays = !isWeeklyResetHoursOnly(item); + const format = resolveNumberFormat('percent', item, settings); if (context.isPreview) { const previewPercent = inverted ? 90.0 : 10.0; @@ -184,13 +189,13 @@ export class WeeklyResetTimerWidget implements Widget { if (isUsageProgressMode(displayMode)) { const barWidth = getUsageProgressBarWidth(displayMode); const progressBar = makeTimerProgressBar(previewPercent, barWidth); - return formatRawOrLabeledValue(item, 'Weekly Reset ', `[${progressBar}] ${previewPercent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, 'Weekly Reset ', `[${progressBar}] ${formatPercent(previewPercent, format)}`); } if (isUsageSliderMode(displayMode)) { const slider = makeSliderBar(previewPercent); const sliderDisplay = displayMode === 'slider' - ? `${slider} ${previewPercent.toFixed(1)}%` + ? `${slider} ${formatPercent(previewPercent, format)}` : slider; return formatRawOrLabeledValue(item, 'Weekly Reset ', sliderDisplay); } @@ -229,15 +234,14 @@ export class WeeklyResetTimerWidget implements Widget { const barWidth = getUsageProgressBarWidth(displayMode); const percent = inverted ? window.remainingPercent : window.elapsedPercent; const progressBar = makeTimerProgressBar(percent, barWidth); - const percentage = percent.toFixed(1); - return formatRawOrLabeledValue(item, 'Weekly Reset ', `[${progressBar}] ${percentage}%`); + return formatRawOrLabeledValue(item, 'Weekly Reset ', `[${progressBar}] ${formatPercent(percent, format)}`); } if (isUsageSliderMode(displayMode)) { const percent = inverted ? window.remainingPercent : window.elapsedPercent; const slider = makeSliderBar(percent); const sliderDisplay = displayMode === 'slider' - ? `${slider} ${percent.toFixed(1)}%` + ? `${slider} ${formatPercent(percent, format)}` : slider; return formatRawOrLabeledValue(item, 'Weekly Reset ', sliderDisplay); } diff --git a/src/widgets/WeeklySonnetUsage.ts b/src/widgets/WeeklySonnetUsage.ts index 6e1218df..f9ed900b 100644 --- a/src/widgets/WeeklySonnetUsage.ts +++ b/src/widgets/WeeklySonnetUsage.ts @@ -6,6 +6,10 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { + formatPercent, + resolveNumberFormat +} from '../utils/number-format'; import { getUsageErrorMessage, resolveWeeklySonnetUsageWindow @@ -63,6 +67,7 @@ export class WeeklySonnetUsageWidget implements Widget { const displayMode = getUsageDisplayMode(item); const inverted = isUsageInverted(item); const showCursor = isUsageCursorEnabled(item); + const format = resolveNumberFormat('percent', item, settings); if (context.isPreview) { const previewPercent = 8; @@ -71,17 +76,17 @@ export class WeeklySonnetUsageWidget implements Widget { if (isUsageProgressMode(displayMode)) { const width = getUsageProgressBarWidth(displayMode); const progressBar = makeTimerProgressBar(renderedPercent, width, showCursor ? { cursorPercent: 50 } : undefined); - const progressDisplay = `[${progressBar}] ${renderedPercent.toFixed(1)}%`; + const progressDisplay = `[${progressBar}] ${formatPercent(renderedPercent, format)}`; 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; + const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(renderedPercent, format)}` : slider; return formatRawOrLabeledValue(item, LABEL, sliderDisplay); } - return formatRawOrLabeledValue(item, LABEL, `${renderedPercent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, LABEL, formatPercent(renderedPercent, format)); } const data = context.usageData ?? {}; @@ -106,17 +111,17 @@ export class WeeklySonnetUsageWidget implements Widget { const width = getUsageProgressBarWidth(displayMode); const progressBar = makeTimerProgressBar(renderedPercent, width, getCursorOptions()); - const progressDisplay = `[${progressBar}] ${renderedPercent.toFixed(1)}%`; + const progressDisplay = `[${progressBar}] ${formatPercent(renderedPercent, format)}`; return formatRawOrLabeledValue(item, LABEL, progressDisplay); } if (isUsageSliderMode(displayMode)) { const slider = makeSliderBar(renderedPercent, undefined, getCursorOptions()); - const sliderDisplay = displayMode === 'slider' ? `${slider} ${renderedPercent.toFixed(1)}%` : slider; + const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(renderedPercent, format)}` : slider; return formatRawOrLabeledValue(item, LABEL, sliderDisplay); } - return formatRawOrLabeledValue(item, LABEL, `${renderedPercent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, LABEL, formatPercent(renderedPercent, format)); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { diff --git a/src/widgets/WeeklyUsage.ts b/src/widgets/WeeklyUsage.ts index 7523e381..bd3ab105 100644 --- a/src/widgets/WeeklyUsage.ts +++ b/src/widgets/WeeklyUsage.ts @@ -6,6 +6,10 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { + formatPercent, + resolveNumberFormat +} from '../utils/number-format'; import { getUsageErrorMessage, resolveWeeklyUsageWindow @@ -61,6 +65,7 @@ export class WeeklyUsageWidget implements Widget { const displayMode = getUsageDisplayMode(item); const inverted = isUsageInverted(item); const showCursor = isUsageCursorEnabled(item); + const format = resolveNumberFormat('percent', item, settings); if (context.isPreview) { const previewPercent = 12; @@ -69,17 +74,17 @@ export class WeeklyUsageWidget implements Widget { if (isUsageProgressMode(displayMode)) { const width = getUsageProgressBarWidth(displayMode); const progressBar = makeTimerProgressBar(renderedPercent, width, showCursor ? { cursorPercent: 50 } : undefined); - const progressDisplay = `[${progressBar}] ${renderedPercent.toFixed(1)}%`; + const progressDisplay = `[${progressBar}] ${formatPercent(renderedPercent, format)}`; return formatRawOrLabeledValue(item, 'Weekly: ', progressDisplay); } if (isUsageSliderMode(displayMode)) { const slider = makeSliderBar(renderedPercent, undefined, showCursor ? { cursorPercent: 50 } : undefined); - const sliderDisplay = displayMode === 'slider' ? `${slider} ${renderedPercent.toFixed(1)}%` : slider; + const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(renderedPercent, format)}` : slider; return formatRawOrLabeledValue(item, 'Weekly: ', sliderDisplay); } - return formatRawOrLabeledValue(item, 'Weekly: ', `${renderedPercent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, 'Weekly: ', formatPercent(renderedPercent, format)); } const data = context.usageData ?? {}; @@ -104,17 +109,17 @@ export class WeeklyUsageWidget implements Widget { const width = getUsageProgressBarWidth(displayMode); const progressBar = makeTimerProgressBar(renderedPercent, width, getCursorOptions()); - const progressDisplay = `[${progressBar}] ${renderedPercent.toFixed(1)}%`; + const progressDisplay = `[${progressBar}] ${formatPercent(renderedPercent, format)}`; return formatRawOrLabeledValue(item, 'Weekly: ', progressDisplay); } if (isUsageSliderMode(displayMode)) { const slider = makeSliderBar(renderedPercent, undefined, getCursorOptions()); - const sliderDisplay = displayMode === 'slider' ? `${slider} ${renderedPercent.toFixed(1)}%` : slider; + const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(renderedPercent, format)}` : slider; return formatRawOrLabeledValue(item, 'Weekly: ', sliderDisplay); } - return formatRawOrLabeledValue(item, 'Weekly: ', `${renderedPercent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, 'Weekly: ', formatPercent(renderedPercent, format)); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { diff --git a/src/widgets/shared/cache-metrics.ts b/src/widgets/shared/cache-metrics.ts index 5a8f6b19..2100a188 100644 --- a/src/widgets/shared/cache-metrics.ts +++ b/src/widgets/shared/cache-metrics.ts @@ -1,8 +1,10 @@ +import type { NumberFormat } from '../../types/NumberFormat'; import type { RenderContext } from '../../types/RenderContext'; import { getContextWindowTurnCacheTokens, type TurnCacheTokens } from '../../utils/context-window'; +import { formatPercent } from '../../utils/number-format'; import { formatTokens } from '../../utils/renderer'; // Resolves the cache token triple for either scope: @@ -46,7 +48,12 @@ export function getCacheWritePercentage(tokens: TurnCacheTokens): number | null // Combines a token count with its context share: "88.0k (84.5%)". // Falls back to the bare token count when the percentage is undefined. -export function formatTokensWithPercentage(tokenCount: number, percentage: number | null): string { - const tokens = formatTokens(tokenCount); - return percentage === null ? tokens : `${tokens} (${percentage.toFixed(1)}%)`; +export function formatTokensWithPercentage( + tokenCount: number, + percentage: number | null, + tokenFormat: NumberFormat = {}, + percentFormat: NumberFormat = {} +): string { + const tokens = formatTokens(tokenCount, tokenFormat); + return percentage === null ? tokens : `${tokens} (${formatPercent(percentage, percentFormat)})`; } diff --git a/src/widgets/shared/context-slider.ts b/src/widgets/shared/context-slider.ts index 9157b0e8..253bf8c9 100644 --- a/src/widgets/shared/context-slider.ts +++ b/src/widgets/shared/context-slider.ts @@ -1,7 +1,9 @@ +import type { NumberFormat } from '../../types/NumberFormat'; import type { CustomKeybind, WidgetItem } from '../../types/Widget'; +import { formatPercent } from '../../utils/number-format'; import { makeSliderBar } from './usage-display'; @@ -43,13 +45,13 @@ export function cycleContextSliderMode(item: WidgetItem): WidgetItem { }; } -export function renderContextSlider(mode: ContextSliderMode, percent: number): string | null { +export function renderContextSlider(mode: ContextSliderMode, percent: number, format: NumberFormat = {}): string | null { if (mode === 'none') { return null; } const slider = makeSliderBar(percent); if (mode === 'slider') { - return `${slider} ${percent.toFixed(1)}%`; + return `${slider} ${formatPercent(percent, format)}`; } return slider; } diff --git a/src/widgets/shared/speed-widget.tsx b/src/widgets/shared/speed-widget.tsx index efaf313f..e0ad70bd 100644 --- a/src/widgets/shared/speed-widget.tsx +++ b/src/widgets/shared/speed-widget.tsx @@ -6,6 +6,7 @@ import { import React, { useState } from 'react'; import type { RenderContext } from '../../types/RenderContext'; +import type { Settings } from '../../types/Settings'; import type { SpeedMetrics } from '../../types/SpeedMetrics'; import type { CustomKeybind, @@ -14,6 +15,7 @@ import type { WidgetItem } from '../../types/Widget'; import { shouldInsertInput } from '../../utils/input-guards'; +import { resolveNumberFormat } from '../../utils/number-format'; import { calculateInputSpeed, calculateOutputSpeed, @@ -110,14 +112,14 @@ export function getSpeedWidgetEditorDisplay(kind: SpeedWidgetKind, item: WidgetI export function renderSpeedWidgetValue( kind: SpeedWidgetKind, item: WidgetItem, - context: RenderContext + context: RenderContext, + settings: Settings ): string | null { const config = SPEED_WIDGET_CONFIG[kind]; - const previewValue = isWidgetSpeedWindowEnabled(item) - ? config.windowedPreview - : config.sessionPreview; + const format = resolveNumberFormat('speed', item, settings); if (context.isPreview) { + const previewValue = isWidgetSpeedWindowEnabled(item) ? config.windowedPreview : config.sessionPreview; return formatRawOrLabeledValue(item, config.label, previewValue); } @@ -127,7 +129,7 @@ export function renderSpeedWidgetValue( } const speed = calculateSpeed(kind, metrics); - return formatRawOrLabeledValue(item, config.label, formatSpeed(speed)); + return formatRawOrLabeledValue(item, config.label, formatSpeed(speed, format)); } export function getSpeedWidgetCustomKeybinds(): CustomKeybind[] { From c343d8d3bf8d475c2a619b4d6a1ac66b058df30e Mon Sep 17 00:00:00 2001 From: Zach Date: Wed, 5 Aug 2026 14:17:00 -0500 Subject: [PATCH 2/3] refactor(number-format): move the precision cycle to the line editor Widgets declare supportsNumberFormat(), matching the existing supportsRawValue / supportsColors convention, and the items editor injects a shared precision keybind for any widget that returns true. The cycle itself moves to utils/number-format.ts so all number-format logic lives in one module. This keeps every non-color override on the line editor screen, where the rest of them already live, and keeps precision reachable while a powerline theme is active. The color menu goes back to color, bold and dim only. The keybind is '.', reading as the decimal point, because every letter is already taken by a widget bind or an editor-level one. The items-editor dispatcher no longer bails early for widgets that expose no keybinds of their own, so plain numeric widgets receive the injected bind. Fable Weekly Usage, which landed since this branch opened, is threaded through formatPercent too. --- src/tui/components/ColorMenu.tsx | 16 +----- src/tui/components/ItemsEditor.tsx | 10 ++-- .../color-menu/__tests__/mutations.test.ts | 31 ----------- src/tui/components/color-menu/mutations.ts | 36 ------------- .../__tests__/input-handlers.test.ts | 50 +++++++++++++++++- .../components/items-editor/input-handlers.ts | 14 ++++- src/types/Widget.ts | 4 ++ src/utils/__tests__/number-format.test.ts | 51 +++++++++++++++++++ src/utils/number-format.ts | 50 +++++++++++++++++- src/widgets/BlockResetTimer.ts | 1 + src/widgets/BlockTimer.ts | 1 + src/widgets/CacheHitRate.ts | 1 + src/widgets/CacheRead.ts | 1 + src/widgets/CacheWrite.ts | 1 + src/widgets/CompactionCounter.ts | 1 + src/widgets/ContextBar.ts | 1 + src/widgets/ContextLength.ts | 1 + src/widgets/ContextPercentage.ts | 1 + src/widgets/ContextPercentageUsable.ts | 1 + src/widgets/ContextWindow.ts | 1 + src/widgets/ExtraUsageUtilization.ts | 1 + src/widgets/FableWeeklyUsage.ts | 18 ++++--- src/widgets/FreeMemory.ts | 1 + src/widgets/InputSpeed.ts | 1 + src/widgets/OutputSpeed.ts | 1 + src/widgets/SessionCost.ts | 1 + src/widgets/SessionUsage.ts | 1 + src/widgets/TokensCached.ts | 1 + src/widgets/TokensInput.ts | 1 + src/widgets/TokensOutput.ts | 1 + src/widgets/TokensTotal.ts | 1 + src/widgets/TotalSpeed.ts | 1 + src/widgets/WeeklyOpusUsage.ts | 1 + src/widgets/WeeklyResetTimer.ts | 1 + src/widgets/WeeklySonnetUsage.ts | 1 + src/widgets/WeeklyUsage.ts | 1 + 36 files changed, 210 insertions(+), 96 deletions(-) diff --git a/src/tui/components/ColorMenu.tsx b/src/tui/components/ColorMenu.tsx index af239267..41a941b4 100644 --- a/src/tui/components/ColorMenu.tsx +++ b/src/tui/components/ColorMenu.tsx @@ -24,7 +24,6 @@ import { clearAllWidgetStyling, cycleWidgetColor, cycleWidgetDim, - cycleWidgetNumberStyle, resetWidgetStyling, setWidgetColor, toggleWidgetBold @@ -279,15 +278,6 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin onUpdate(newItems); } } - } else if (input === 'n' || input === 'N') { - if (highlightedItemId && highlightedItemId !== 'back') { - // Cycle number format style for the highlighted item: default -> compact -> whole -> default - const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId); - if (selectedWidget) { - const newItems = cycleWidgetNumberStyle(widgets, selectedWidget.id); - onUpdate(newItems); - } - } } else if (input === 'r' || input === 'R') { if (highlightedItemId && highlightedItemId !== 'back') { // Reset all styling (color, background, and bold) for the highlighted item @@ -454,9 +444,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin const styleIndicators = [ selectedWidget?.bold ? '[BOLD]' : null, selectedWidget?.dim === true ? '[DIM]' : null, - selectedWidget?.dim === 'parens' ? '[DIM ()]' : null, - selectedWidget?.numberFormat?.style ? `[#${selectedWidget.numberFormat.style}]` : null, - selectedWidget?.numberFormat?.decimals !== undefined ? `[#${selectedWidget.numberFormat.decimals}dp]` : null + selectedWidget?.dim === 'parens' ? '[DIM ()]' : null ].filter(indicator => indicator !== null).join(' '); // Gradient selection mode takes over the whole view @@ -600,7 +588,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin ↑↓ to select, ←→ to cycle {' '} {editingBackground ? 'background' : 'foreground'} - , (f) to toggle bg/fg, (b)old, (d)im, (n)umber, + , (f) to toggle bg/fg, (b)old, (d)im, {settings.colorLevel === 3 ? ' (h)ex,' : settings.colorLevel === 2 ? ' (a)nsi256,' : ''} {!editingBackground && settings.colorLevel >= 2 ? ' (g)radient,' : ''} {' '} diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index b9257ec3..1d3d384e 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -14,6 +14,7 @@ import type { } from '../../types/Widget'; import { getBackgroundColorsForPowerline } from '../../utils/colors'; import { generateGuid } from '../../utils/guid'; +import { getNumberFormatKeybind } from '../../utils/number-format'; import { canDetectTerminalWidth } from '../../utils/terminal'; import { filterWidgetCatalog, @@ -104,11 +105,12 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB }; const getCustomKeybindsForWidget = (widgetImpl: Widget, widget: WidgetItem): CustomKeybind[] => { - if (!widgetImpl.getCustomKeybinds) { - return []; - } + const keybinds = widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : []; - return widgetImpl.getCustomKeybinds(widget); + // Numeric widgets get the precision cycle here rather than in the color + // menu, so every non-color override stays on this screen and stays + // reachable while a powerline theme is active. + return widgetImpl.supportsNumberFormat?.() ? [...keybinds, getNumberFormatKeybind()] : keybinds; }; const openWidgetPicker = (action: WidgetPickerAction) => { diff --git a/src/tui/components/color-menu/__tests__/mutations.test.ts b/src/tui/components/color-menu/__tests__/mutations.test.ts index 63a39cee..2a506f9b 100644 --- a/src/tui/components/color-menu/__tests__/mutations.test.ts +++ b/src/tui/components/color-menu/__tests__/mutations.test.ts @@ -9,7 +9,6 @@ import { clearAllWidgetStyling, cycleWidgetColor, cycleWidgetDim, - cycleWidgetNumberStyle, resetWidgetStyling, toggleWidgetBold, updateWidgetById @@ -59,36 +58,6 @@ describe('color-menu mutations', () => { expect(whole[1]?.dim).toBeUndefined(); }); - it('cycleWidgetNumberStyle cycles default, compact, whole, then default for the selected widget only', () => { - const widgets: WidgetItem[] = [ - { id: '1', type: 'tokens-input' }, - { id: '2', type: 'tokens-output' } - ]; - - const compact = cycleWidgetNumberStyle(widgets, '1'); - const whole = cycleWidgetNumberStyle(compact, '1'); - const off = cycleWidgetNumberStyle(whole, '1'); - - expect(compact[0]?.numberFormat).toEqual({ style: 'compact' }); - expect(whole[0]?.numberFormat).toEqual({ style: 'whole' }); - expect(off[0]).toEqual({ id: '1', type: 'tokens-input' }); - expect(compact[1]?.numberFormat).toBeUndefined(); - }); - - it('cycleWidgetNumberStyle preserves an explicit decimals across the cycle', () => { - const widgets: WidgetItem[] = [ - { id: '1', type: 'tokens-input', numberFormat: { decimals: 2 } } - ]; - - const compact = cycleWidgetNumberStyle(widgets, '1'); - const whole = cycleWidgetNumberStyle(compact, '1'); - const off = cycleWidgetNumberStyle(whole, '1'); - - expect(compact[0]?.numberFormat).toEqual({ style: 'compact', decimals: 2 }); - expect(whole[0]?.numberFormat).toEqual({ style: 'whole', decimals: 2 }); - expect(off[0]?.numberFormat).toEqual({ decimals: 2 }); - }); - it('resetWidgetStyling removes color, backgroundColor, bold, dim, and numberFormat from one widget', () => { const widgets: WidgetItem[] = [ { diff --git a/src/tui/components/color-menu/mutations.ts b/src/tui/components/color-menu/mutations.ts index b5509b6c..ce13a3b9 100644 --- a/src/tui/components/color-menu/mutations.ts +++ b/src/tui/components/color-menu/mutations.ts @@ -1,7 +1,3 @@ -import type { - NumberFormat, - NumberStyle -} from '../../../types/NumberFormat'; import type { WidgetItem } from '../../../types/Widget'; import { getWidget } from '../../../utils/widgets'; @@ -64,38 +60,6 @@ export function cycleWidgetDim(widgets: WidgetItem[], widgetId: string): WidgetI }); } -export function cycleWidgetNumberStyle(widgets: WidgetItem[], widgetId: string): WidgetItem[] { - return updateWidgetById(widgets, widgetId, (widget) => { - // Cycle the number style: default (precise) -> compact -> whole -> default. - // Any explicit `decimals` is preserved across the cycle. - const currentStyle = widget.numberFormat?.style; - const nextStyle: NumberStyle | undefined = currentStyle === undefined - ? 'compact' - : currentStyle === 'compact' - ? 'whole' - : undefined; - const decimals = widget.numberFormat?.decimals; - - if (nextStyle === undefined && decimals === undefined) { - const { numberFormat, ...restWidget } = widget; - void numberFormat; // Intentionally unused - return restWidget; - } - - const nextFormat: NumberFormat = {}; - if (nextStyle !== undefined) { - nextFormat.style = nextStyle; - } - if (decimals !== undefined) { - nextFormat.decimals = decimals; - } - return { - ...widget, - numberFormat: nextFormat - }; - }); -} - export function resetWidgetStyling(widgets: WidgetItem[], widgetId: string): WidgetItem[] { return updateWidgetById(widgets, widgetId, (widget) => { const { diff --git a/src/tui/components/items-editor/__tests__/input-handlers.test.ts b/src/tui/components/items-editor/__tests__/input-handlers.test.ts index a811881d..7fb32221 100644 --- a/src/tui/components/items-editor/__tests__/input-handlers.test.ts +++ b/src/tui/components/items-editor/__tests__/input-handlers.test.ts @@ -5,7 +5,12 @@ import { vi } from 'vitest'; -import type { WidgetItem } from '../../../../types/Widget'; +import type { + CustomKeybind, + Widget, + WidgetItem +} from '../../../../types/Widget'; +import { getNumberFormatKeybind } from '../../../../utils/number-format'; import type { WidgetCatalogEntry } from '../../../../utils/widgets'; import { handleMoveInputMode, @@ -1003,4 +1008,47 @@ describe('items-editor input handlers', () => { expect(setSelectedIndex).not.toHaveBeenCalled(); }); }); + + describe('precision keybind', () => { + // The items editor injects this bind for numeric widgets, so the handler + // applies it itself rather than delegating to handleEditorAction. + const injectPrecisionKeybind = (widgetImpl: Widget, widget: WidgetItem): CustomKeybind[] => { + const keybinds = widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : []; + return widgetImpl.supportsNumberFormat?.() ? [...keybinds, getNumberFormatKeybind()] : keybinds; + }; + + const pressPrecision = (widgets: WidgetItem[], onUpdate: (widgets: WidgetItem[]) => void) => { + handleNormalInputMode({ + input: '.', + key: {}, + widgets, + selectedIndex: 0, + separatorChars: ['|'], + onBack: vi.fn(), + onUpdate, + setSelectedIndex: vi.fn(), + setMoveMode: vi.fn(), + setShowClearConfirm: vi.fn(), + openWidgetPicker: vi.fn(), + getCustomKeybindsForWidget: injectPrecisionKeybind, + setCustomEditorWidget: vi.fn() + }); + }; + + it('cycles the number style of a numeric widget that has no keybinds of its own', () => { + const onUpdate = vi.fn(); + pressPrecision([{ id: '1', type: 'tokens-input' }], onUpdate); + + expect(onUpdate).toHaveBeenCalledWith([ + { id: '1', type: 'tokens-input', numberFormat: { style: 'compact' } } + ]); + }); + + it('leaves a non-numeric widget untouched', () => { + const onUpdate = vi.fn(); + pressPrecision([{ id: '1', type: 'custom-text' }], onUpdate); + + expect(onUpdate).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/tui/components/items-editor/input-handlers.ts b/src/tui/components/items-editor/input-handlers.ts index fab43416..12ed9e3d 100644 --- a/src/tui/components/items-editor/input-handlers.ts +++ b/src/tui/components/items-editor/input-handlers.ts @@ -5,6 +5,10 @@ import type { WidgetItemType } from '../../../types/Widget'; import { generateGuid } from '../../../utils/guid'; +import { + CYCLE_NUMBER_STYLE_ACTION, + cycleNumberStyle +} from '../../../utils/number-format'; import { filterWidgetCatalog, getWidget, @@ -476,7 +480,7 @@ export function handleNormalInputMode({ const currentWidget = widgets[selectedIndex]; if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') { const widgetImpl = getWidget(currentWidget.type); - if (!widgetImpl?.getCustomKeybinds) { + if (!widgetImpl) { return; } @@ -484,7 +488,13 @@ export function handleNormalInputMode({ const matchedKeybind = customKeybinds.find(kb => kb.key === input); if (matchedKeybind && !key.ctrl) { - if (widgetImpl.handleEditorAction) { + // The precision cycle is shared by every numeric widget, so it is + // applied here instead of in each widget's handleEditorAction. + if (matchedKeybind.action === CYCLE_NUMBER_STYLE_ACTION) { + const newWidgets = [...widgets]; + newWidgets[selectedIndex] = cycleNumberStyle(currentWidget); + onUpdate(newWidgets); + } else if (widgetImpl.handleEditorAction) { const updatedWidget = widgetImpl.handleEditorAction(matchedKeybind.action, currentWidget); if (updatedWidget) { const newWidgets = [...widgets]; diff --git a/src/types/Widget.ts b/src/types/Widget.ts index 198f7379..de44725e 100644 --- a/src/types/Widget.ts +++ b/src/types/Widget.ts @@ -47,6 +47,10 @@ export interface Widget { renderEditor?(props: WidgetEditorProps): React.ReactElement | null; supportsRawValue(): boolean; supportsColors(item: WidgetItem): boolean; + // Whether the widget renders a number whose precision can be overridden. + // Gates the items editor's precision keybind; widgets that omit it are + // treated as non-numeric. + supportsNumberFormat?(): boolean; handleEditorAction?(action: string, item: WidgetItem): WidgetItem | null; getNumericValue?(context: RenderContext, item: WidgetItem): number | null; } diff --git a/src/utils/__tests__/number-format.test.ts b/src/utils/__tests__/number-format.test.ts index f7281f60..5751d373 100644 --- a/src/utils/__tests__/number-format.test.ts +++ b/src/utils/__tests__/number-format.test.ts @@ -12,8 +12,11 @@ import { DEFAULT_SETTINGS } from '../../types/Settings'; import type { WidgetItem } from '../../types/Widget'; import { formatTokens } from '../format-tokens'; import { + CYCLE_NUMBER_STYLE_ACTION, + cycleNumberStyle, formatCost, formatPercent, + getNumberFormatKeybind, renderMagnitude, resolveNumberFormat } from '../number-format'; @@ -114,6 +117,54 @@ describe('formatTokens with a format', () => { }); }); +describe('cycleNumberStyle', () => { + it('cycles default, compact, whole, then back to default', () => { + const item: WidgetItem = { id: '1', type: 'tokens-input' }; + + const compact = cycleNumberStyle(item); + const whole = cycleNumberStyle(compact); + const off = cycleNumberStyle(whole); + + expect(compact.numberFormat).toEqual({ style: 'compact' }); + expect(whole.numberFormat).toEqual({ style: 'whole' }); + expect(off).toEqual({ id: '1', type: 'tokens-input' }); + }); + + it('preserves an explicit decimals across the cycle', () => { + const item: WidgetItem = { id: '1', type: 'tokens-input', numberFormat: { decimals: 2 } }; + + const compact = cycleNumberStyle(item); + const whole = cycleNumberStyle(compact); + const off = cycleNumberStyle(whole); + + expect(compact.numberFormat).toEqual({ style: 'compact', decimals: 2 }); + expect(whole.numberFormat).toEqual({ style: 'whole', decimals: 2 }); + expect(off.numberFormat).toEqual({ decimals: 2 }); + }); + + it('leaves other widget fields untouched', () => { + const item: WidgetItem = { id: '1', type: 'tokens-input', color: 'blue', bold: true }; + + expect(cycleNumberStyle(item)).toEqual({ + id: '1', + type: 'tokens-input', + color: 'blue', + bold: true, + numberFormat: { style: 'compact' } + }); + }); +}); + +describe('getNumberFormatKeybind', () => { + it('binds the precision cycle to a key no widget uses', () => { + expect(getNumberFormatKeybind()).toEqual({ + key: '.', + label: '(.) precision', + action: CYCLE_NUMBER_STYLE_ACTION + }); + }); +}); + describe('formatSpeed with a format', () => { it('compact trims trailing zeros', () => { expect(formatSpeed(1000, { style: 'compact' })).toBe('1k t/s'); diff --git a/src/utils/number-format.ts b/src/utils/number-format.ts index 4aa0967c..92e1c504 100644 --- a/src/utils/number-format.ts +++ b/src/utils/number-format.ts @@ -1,9 +1,55 @@ import type { NumberFormat, - NumberKind + NumberKind, + NumberStyle } from '../types/NumberFormat'; import type { Settings } from '../types/Settings'; -import type { WidgetItem } from '../types/Widget'; +import type { + CustomKeybind, + WidgetItem +} from '../types/Widget'; + +// Editor action and keybind for the per-widget precision cycle. The items editor +// injects this for any widget whose supportsNumberFormat() is true, so a numeric +// widget needs no keybind wiring of its own. '.' reads as the decimal point and +// keeps every letter free for widget-specific binds. +export const CYCLE_NUMBER_STYLE_ACTION = 'cycle-number-style'; +const NUMBER_FORMAT_KEYBIND: CustomKeybind = { key: '.', label: '(.) precision', action: CYCLE_NUMBER_STYLE_ACTION }; + +export function getNumberFormatKeybind(): CustomKeybind { + return NUMBER_FORMAT_KEYBIND; +} + +// Cycle the number style: default (precise) -> compact -> whole -> default. +// Any explicit `decimals` is preserved across the cycle. +export function cycleNumberStyle(item: WidgetItem): WidgetItem { + const currentStyle = item.numberFormat?.style; + const nextStyle: NumberStyle | undefined = currentStyle === undefined + ? 'compact' + : currentStyle === 'compact' + ? 'whole' + : undefined; + const decimals = item.numberFormat?.decimals; + + if (nextStyle === undefined && decimals === undefined) { + const { numberFormat, ...restItem } = item; + void numberFormat; // Intentionally unused + return restItem; + } + + const nextFormat: NumberFormat = {}; + if (nextStyle !== undefined) { + nextFormat.style = nextStyle; + } + if (decimals !== undefined) { + nextFormat.decimals = decimals; + } + + return { + ...item, + numberFormat: nextFormat + }; +} // Strip a pointless trailing ".0" (or ".00", ...) so a whole value reads cleanly // while a meaningful fraction is left intact. diff --git a/src/widgets/BlockResetTimer.ts b/src/widgets/BlockResetTimer.ts index 0518cae7..b23c9d72 100644 --- a/src/widgets/BlockResetTimer.ts +++ b/src/widgets/BlockResetTimer.ts @@ -198,4 +198,5 @@ export class BlockResetTimerWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/BlockTimer.ts b/src/widgets/BlockTimer.ts index 738171f4..33f4fcf0 100644 --- a/src/widgets/BlockTimer.ts +++ b/src/widgets/BlockTimer.ts @@ -140,4 +140,5 @@ export class BlockTimerWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/CacheHitRate.ts b/src/widgets/CacheHitRate.ts index 900c5a3f..46627f21 100644 --- a/src/widgets/CacheHitRate.ts +++ b/src/widgets/CacheHitRate.ts @@ -67,4 +67,5 @@ export class CacheHitRateWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/CacheRead.ts b/src/widgets/CacheRead.ts index 54b98166..71a3e3af 100644 --- a/src/widgets/CacheRead.ts +++ b/src/widgets/CacheRead.ts @@ -62,4 +62,5 @@ export class CacheReadWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/CacheWrite.ts b/src/widgets/CacheWrite.ts index d9f8b663..73aac7b2 100644 --- a/src/widgets/CacheWrite.ts +++ b/src/widgets/CacheWrite.ts @@ -62,4 +62,5 @@ export class CacheWriteWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/CompactionCounter.ts b/src/widgets/CompactionCounter.ts index e95e3829..c031ad58 100644 --- a/src/widgets/CompactionCounter.ts +++ b/src/widgets/CompactionCounter.ts @@ -295,4 +295,5 @@ export class CompactionCounterWidget implements Widget { supportsRawValue(): boolean { return false; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/ContextBar.ts b/src/widgets/ContextBar.ts index 89a2454a..55122d16 100644 --- a/src/widgets/ContextBar.ts +++ b/src/widgets/ContextBar.ts @@ -141,4 +141,5 @@ export class ContextBarWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/ContextLength.ts b/src/widgets/ContextLength.ts index 92575f71..5416b387 100644 --- a/src/widgets/ContextLength.ts +++ b/src/widgets/ContextLength.ts @@ -37,4 +37,5 @@ export class ContextLengthWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/ContextPercentage.ts b/src/widgets/ContextPercentage.ts index 69aff8b3..371a0136 100644 --- a/src/widgets/ContextPercentage.ts +++ b/src/widgets/ContextPercentage.ts @@ -82,4 +82,5 @@ export class ContextPercentageWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/ContextPercentageUsable.ts b/src/widgets/ContextPercentageUsable.ts index e5dc14e0..9e8c65b3 100644 --- a/src/widgets/ContextPercentageUsable.ts +++ b/src/widgets/ContextPercentageUsable.ts @@ -94,4 +94,5 @@ export class ContextPercentageUsableWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/ContextWindow.ts b/src/widgets/ContextWindow.ts index 51d12e9f..698b5d8e 100644 --- a/src/widgets/ContextWindow.ts +++ b/src/widgets/ContextWindow.ts @@ -44,4 +44,5 @@ export class ContextWindowWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/ExtraUsageUtilization.ts b/src/widgets/ExtraUsageUtilization.ts index cbb8bbb1..174788d0 100644 --- a/src/widgets/ExtraUsageUtilization.ts +++ b/src/widgets/ExtraUsageUtilization.ts @@ -127,4 +127,5 @@ export class ExtraUsageUtilizationWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/FableWeeklyUsage.ts b/src/widgets/FableWeeklyUsage.ts index ffc52622..d34d8fb1 100644 --- a/src/widgets/FableWeeklyUsage.ts +++ b/src/widgets/FableWeeklyUsage.ts @@ -6,6 +6,10 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { + formatPercent, + resolveNumberFormat +} from '../utils/number-format'; import { getUsageErrorMessage, resolveFableUsageWindow @@ -60,6 +64,7 @@ export class FableWeeklyUsageWidget implements Widget { } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + const format = resolveNumberFormat('percent', item, settings); const displayMode = getUsageDisplayMode(item); const inverted = isUsageInverted(item); const showCursor = isUsageCursorEnabled(item); @@ -71,17 +76,17 @@ export class FableWeeklyUsageWidget implements Widget { if (isUsageProgressMode(displayMode)) { const width = getUsageProgressBarWidth(displayMode); const progressBar = makeTimerProgressBar(renderedPercent, width, showCursor ? { cursorPercent: 50 } : undefined); - const progressDisplay = `[${progressBar}] ${renderedPercent.toFixed(1)}%`; + const progressDisplay = `[${progressBar}] ${formatPercent(renderedPercent, format)}`; 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; + const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(renderedPercent, format)}` : slider; return formatRawOrLabeledValue(item, LABEL, sliderDisplay); } - return formatRawOrLabeledValue(item, LABEL, `${renderedPercent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, LABEL, formatPercent(renderedPercent, format)); } const data = context.usageData ?? {}; @@ -106,17 +111,17 @@ export class FableWeeklyUsageWidget implements Widget { const width = getUsageProgressBarWidth(displayMode); const progressBar = makeTimerProgressBar(renderedPercent, width, getCursorOptions()); - const progressDisplay = `[${progressBar}] ${renderedPercent.toFixed(1)}%`; + const progressDisplay = `[${progressBar}] ${formatPercent(renderedPercent, format)}`; return formatRawOrLabeledValue(item, LABEL, progressDisplay); } if (isUsageSliderMode(displayMode)) { const slider = makeSliderBar(renderedPercent, undefined, getCursorOptions()); - const sliderDisplay = displayMode === 'slider' ? `${slider} ${renderedPercent.toFixed(1)}%` : slider; + const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(renderedPercent, format)}` : slider; return formatRawOrLabeledValue(item, LABEL, sliderDisplay); } - return formatRawOrLabeledValue(item, LABEL, `${renderedPercent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, LABEL, formatPercent(renderedPercent, format)); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { @@ -125,4 +130,5 @@ export class FableWeeklyUsageWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/FreeMemory.ts b/src/widgets/FreeMemory.ts index 2d865f80..b86c9396 100644 --- a/src/widgets/FreeMemory.ts +++ b/src/widgets/FreeMemory.ts @@ -99,4 +99,5 @@ export class FreeMemoryWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/InputSpeed.ts b/src/widgets/InputSpeed.ts index 70321586..bde6d216 100644 --- a/src/widgets/InputSpeed.ts +++ b/src/widgets/InputSpeed.ts @@ -40,4 +40,5 @@ export class InputSpeedWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/OutputSpeed.ts b/src/widgets/OutputSpeed.ts index 43a30a9c..14111878 100644 --- a/src/widgets/OutputSpeed.ts +++ b/src/widgets/OutputSpeed.ts @@ -40,4 +40,5 @@ export class OutputSpeedWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/SessionCost.ts b/src/widgets/SessionCost.ts index 684c8054..cce06114 100644 --- a/src/widgets/SessionCost.ts +++ b/src/widgets/SessionCost.ts @@ -37,4 +37,5 @@ export class SessionCostWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/SessionUsage.ts b/src/widgets/SessionUsage.ts index 06418ae5..b8c22b52 100644 --- a/src/widgets/SessionUsage.ts +++ b/src/widgets/SessionUsage.ts @@ -128,4 +128,5 @@ export class SessionUsageWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/TokensCached.ts b/src/widgets/TokensCached.ts index 37d62664..e9a0bf30 100644 --- a/src/widgets/TokensCached.ts +++ b/src/widgets/TokensCached.ts @@ -33,4 +33,5 @@ export class TokensCachedWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/TokensInput.ts b/src/widgets/TokensInput.ts index 6e672f09..27c3007d 100644 --- a/src/widgets/TokensInput.ts +++ b/src/widgets/TokensInput.ts @@ -39,4 +39,5 @@ export class TokensInputWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/TokensOutput.ts b/src/widgets/TokensOutput.ts index 92c0b30e..5c69e05e 100644 --- a/src/widgets/TokensOutput.ts +++ b/src/widgets/TokensOutput.ts @@ -39,4 +39,5 @@ export class TokensOutputWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/TokensTotal.ts b/src/widgets/TokensTotal.ts index 15aa9bb0..10784906 100644 --- a/src/widgets/TokensTotal.ts +++ b/src/widgets/TokensTotal.ts @@ -33,4 +33,5 @@ export class TokensTotalWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/TotalSpeed.ts b/src/widgets/TotalSpeed.ts index 9fa950c5..251e53b6 100644 --- a/src/widgets/TotalSpeed.ts +++ b/src/widgets/TotalSpeed.ts @@ -40,4 +40,5 @@ export class TotalSpeedWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/WeeklyOpusUsage.ts b/src/widgets/WeeklyOpusUsage.ts index b1f96035..ebfe48aa 100644 --- a/src/widgets/WeeklyOpusUsage.ts +++ b/src/widgets/WeeklyOpusUsage.ts @@ -130,4 +130,5 @@ export class WeeklyOpusUsageWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/WeeklyResetTimer.ts b/src/widgets/WeeklyResetTimer.ts index 61e28938..b31cdc81 100644 --- a/src/widgets/WeeklyResetTimer.ts +++ b/src/widgets/WeeklyResetTimer.ts @@ -291,4 +291,5 @@ export class WeeklyResetTimerWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/WeeklySonnetUsage.ts b/src/widgets/WeeklySonnetUsage.ts index f9ed900b..52a6b0c5 100644 --- a/src/widgets/WeeklySonnetUsage.ts +++ b/src/widgets/WeeklySonnetUsage.ts @@ -130,4 +130,5 @@ export class WeeklySonnetUsageWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/WeeklyUsage.ts b/src/widgets/WeeklyUsage.ts index bd3ab105..0e41453d 100644 --- a/src/widgets/WeeklyUsage.ts +++ b/src/widgets/WeeklyUsage.ts @@ -128,4 +128,5 @@ export class WeeklyUsageWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } From b275047d793991de143b783c6cc92a98ded54a18 Mon Sep 17 00:00:00 2001 From: Zach Date: Wed, 5 Aug 2026 21:04:12 -0500 Subject: [PATCH 3/3] refactor(usage): extract the usage-percent widgets onto a shared module SessionUsage, WeeklyUsage, WeeklySonnetUsage, WeeklyOpusUsage and FableWeeklyUsage carried five copies of one render body. Normalized for the per-widget names, the worst pair differed by three lines and two pairs were identical. Each file is now delegation, following the speed-widget.tsx and InputSpeed.ts pair. Six axes vary and are parameterized: label, preview percent, usage field, window resolver, display name and description. Default color, category, editor display, editor actions, keybinds and the three supports* flags were already uniform. The resolvers dispatch at call time rather than through a captured reference, because the widget tests spy them on the utils/usage namespace and a module-level table would bind before the spy is installed. FableWeeklyUsage's label was the one of the five that disagreed with its own display name, so 'Fable Weekly: ' becomes 'Weekly Fable: ', matching the sibling rule of display name minus ' Usage'. Raw mode prints no label and is unaffected. The widget type, class name and display name are untouched. ExtraUsageUtilization stays out: it gates on a tri-state enabled flag, has no window resolver, no time cursor and a different default color. The shared suite gains a whole-percent case. formatPercent's format argument is optional and its default reproduces the baseline, so a render path that stopped passing the resolved format rendered identically under default settings and no test could see it. --- src/widgets/FableWeeklyUsage.ts | 114 ++-------- src/widgets/SessionUsage.ts | 112 ++-------- src/widgets/WeeklyOpusUsage.ts | 114 ++-------- src/widgets/WeeklySonnetUsage.ts | 114 ++-------- src/widgets/WeeklyUsage.ts | 112 ++-------- .../__tests__/FableWeeklyUsage.test.ts | 13 +- src/widgets/__tests__/SessionUsage.test.ts | 2 +- src/widgets/__tests__/WeeklyOpusUsage.test.ts | 1 + .../__tests__/WeeklySonnetUsage.test.ts | 1 + src/widgets/__tests__/WeeklyUsage.test.ts | 2 +- .../__tests__/helpers/usage-widget-suites.ts | 16 ++ src/widgets/shared/usage-percent-widget.ts | 202 ++++++++++++++++++ 12 files changed, 289 insertions(+), 514 deletions(-) create mode 100644 src/widgets/shared/usage-percent-widget.ts diff --git a/src/widgets/FableWeeklyUsage.ts b/src/widgets/FableWeeklyUsage.ts index d34d8fb1..29995b5d 100644 --- a/src/widgets/FableWeeklyUsage.ts +++ b/src/widgets/FableWeeklyUsage.ts @@ -6,122 +6,32 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; -import { - formatPercent, - resolveNumberFormat -} from '../utils/number-format'; -import { - getUsageErrorMessage, - resolveFableUsageWindow -} from '../utils/usage'; -import { makeTimerProgressBar } from './shared/progress-bar'; -import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; +import { getUsagePercentCustomKeybinds } from './shared/usage-display'; import { - cycleUsageDisplayMode, - getUsageDisplayMode, - getUsageDisplayModifierText, - getUsagePercentCustomKeybinds, - getUsageProgressBarWidth, - isUsageCursorEnabled, - isUsageInverted, - isUsageProgressMode, - isUsageSliderMode, - makeSliderBar, - toggleUsageCursor, - toggleUsageInverted -} from './shared/usage-display'; - -const LABEL = 'Fable Weekly: '; + getUsagePercentWidgetDescription, + getUsagePercentWidgetDisplayName, + getUsagePercentWidgetEditorDisplay, + handleUsagePercentWidgetEditorAction, + renderUsagePercentWidgetValue +} from './shared/usage-percent-widget'; export class FableWeeklyUsageWidget implements Widget { getDefaultColor(): string { return 'brightBlue'; } - getDescription(): string { return 'Shows Fable-only weekly usage percentage'; } - getDisplayName(): string { return 'Weekly Fable Usage'; } + getDescription(): string { return getUsagePercentWidgetDescription('fable-weekly'); } + getDisplayName(): string { return getUsagePercentWidgetDisplayName('fable-weekly'); } getCategory(): string { return 'Usage'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - return { - displayText: this.getDisplayName(), - modifierText: getUsageDisplayModifierText(item, { showUsageDirection: true }) - }; + return getUsagePercentWidgetEditorDisplay('fable-weekly', item); } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - if (action === 'toggle-progress') { - return cycleUsageDisplayMode(item, [], true, true); - } - - if (action === 'toggle-invert') { - return toggleUsageInverted(item); - } - - if (action === 'toggle-cursor') { - return toggleUsageCursor(item); - } - - return null; + return handleUsagePercentWidgetEditorAction(action, item); } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { - const format = resolveNumberFormat('percent', item, settings); - 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}] ${formatPercent(renderedPercent, format)}`; - return formatRawOrLabeledValue(item, LABEL, progressDisplay); - } - - if (isUsageSliderMode(displayMode)) { - const slider = makeSliderBar(renderedPercent, undefined, showCursor ? { cursorPercent: 50 } : undefined); - const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(renderedPercent, format)}` : slider; - return formatRawOrLabeledValue(item, LABEL, sliderDisplay); - } - - return formatRawOrLabeledValue(item, LABEL, formatPercent(renderedPercent, format)); - } - - const data = context.usageData ?? {}; - if (data.fableUsage === undefined) { - if (data.error) - return getUsageErrorMessage(data.error); - return null; - } - - const percent = Math.max(0, Math.min(100, data.fableUsage)); - const renderedPercent = inverted ? 100 - percent : percent; - const getCursorOptions = (): { cursorPercent: number } | undefined => { - if (!showCursor) { - return undefined; - } - - const window = resolveFableUsageWindow(data); - return window ? { cursorPercent: window.elapsedPercent } : undefined; - }; - - if (isUsageProgressMode(displayMode)) { - const width = getUsageProgressBarWidth(displayMode); - - const progressBar = makeTimerProgressBar(renderedPercent, width, getCursorOptions()); - const progressDisplay = `[${progressBar}] ${formatPercent(renderedPercent, format)}`; - return formatRawOrLabeledValue(item, LABEL, progressDisplay); - } - - if (isUsageSliderMode(displayMode)) { - const slider = makeSliderBar(renderedPercent, undefined, getCursorOptions()); - const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(renderedPercent, format)}` : slider; - return formatRawOrLabeledValue(item, LABEL, sliderDisplay); - } - - return formatRawOrLabeledValue(item, LABEL, formatPercent(renderedPercent, format)); + return renderUsagePercentWidgetValue('fable-weekly', item, context, settings); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { diff --git a/src/widgets/SessionUsage.ts b/src/widgets/SessionUsage.ts index b8c22b52..89de73b9 100644 --- a/src/widgets/SessionUsage.ts +++ b/src/widgets/SessionUsage.ts @@ -6,120 +6,32 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; -import { - formatPercent, - resolveNumberFormat -} from '../utils/number-format'; -import { - getUsageErrorMessage, - resolveUsageWindowWithFallback -} from '../utils/usage'; -import { makeTimerProgressBar } from './shared/progress-bar'; -import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; +import { getUsagePercentCustomKeybinds } from './shared/usage-display'; import { - cycleUsageDisplayMode, - getUsageDisplayMode, - getUsageDisplayModifierText, - getUsagePercentCustomKeybinds, - getUsageProgressBarWidth, - isUsageCursorEnabled, - isUsageInverted, - isUsageProgressMode, - isUsageSliderMode, - makeSliderBar, - toggleUsageCursor, - toggleUsageInverted -} from './shared/usage-display'; + getUsagePercentWidgetDescription, + getUsagePercentWidgetDisplayName, + getUsagePercentWidgetEditorDisplay, + handleUsagePercentWidgetEditorAction, + renderUsagePercentWidgetValue +} from './shared/usage-percent-widget'; export class SessionUsageWidget implements Widget { getDefaultColor(): string { return 'brightBlue'; } - getDescription(): string { return 'Shows daily/session API usage percentage'; } - getDisplayName(): string { return 'Session Usage'; } + getDescription(): string { return getUsagePercentWidgetDescription('session'); } + getDisplayName(): string { return getUsagePercentWidgetDisplayName('session'); } getCategory(): string { return 'Usage'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - return { - displayText: this.getDisplayName(), - modifierText: getUsageDisplayModifierText(item, { showUsageDirection: true }) - }; + return getUsagePercentWidgetEditorDisplay('session', item); } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - if (action === 'toggle-progress') { - return cycleUsageDisplayMode(item, [], true, true); - } - - if (action === 'toggle-invert') { - return toggleUsageInverted(item); - } - - if (action === 'toggle-cursor') { - return toggleUsageCursor(item); - } - - return null; + return handleUsagePercentWidgetEditorAction(action, item); } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { - const displayMode = getUsageDisplayMode(item); - const inverted = isUsageInverted(item); - const showCursor = isUsageCursorEnabled(item); - const format = resolveNumberFormat('percent', item, settings); - - if (context.isPreview) { - const previewPercent = 20; - 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}] ${formatPercent(renderedPercent, format)}`; - return formatRawOrLabeledValue(item, 'Session: ', progressDisplay); - } - - if (isUsageSliderMode(displayMode)) { - const slider = makeSliderBar(renderedPercent, undefined, showCursor ? { cursorPercent: 50 } : undefined); - const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(renderedPercent, format)}` : slider; - return formatRawOrLabeledValue(item, 'Session: ', sliderDisplay); - } - - return formatRawOrLabeledValue(item, 'Session: ', formatPercent(renderedPercent, format)); - } - - const data = context.usageData ?? {}; - if (data.sessionUsage === undefined) { - if (data.error) - return getUsageErrorMessage(data.error); - return null; - } - - const percent = Math.max(0, Math.min(100, data.sessionUsage)); - const renderedPercent = inverted ? 100 - percent : percent; - const getCursorOptions = (): { cursorPercent: number } | undefined => { - if (!showCursor) { - return undefined; - } - - const window = resolveUsageWindowWithFallback(data, context.blockMetrics); - return window ? { cursorPercent: window.elapsedPercent } : undefined; - }; - - if (isUsageProgressMode(displayMode)) { - const width = getUsageProgressBarWidth(displayMode); - - const progressBar = makeTimerProgressBar(renderedPercent, width, getCursorOptions()); - const progressDisplay = `[${progressBar}] ${formatPercent(renderedPercent, format)}`; - return formatRawOrLabeledValue(item, 'Session: ', progressDisplay); - } - - if (isUsageSliderMode(displayMode)) { - const slider = makeSliderBar(renderedPercent, undefined, getCursorOptions()); - const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(renderedPercent, format)}` : slider; - return formatRawOrLabeledValue(item, 'Session: ', sliderDisplay); - } - - return formatRawOrLabeledValue(item, 'Session: ', formatPercent(renderedPercent, format)); + return renderUsagePercentWidgetValue('session', item, context, settings); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { diff --git a/src/widgets/WeeklyOpusUsage.ts b/src/widgets/WeeklyOpusUsage.ts index ebfe48aa..22154e42 100644 --- a/src/widgets/WeeklyOpusUsage.ts +++ b/src/widgets/WeeklyOpusUsage.ts @@ -6,122 +6,32 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; -import { - formatPercent, - resolveNumberFormat -} from '../utils/number-format'; -import { - getUsageErrorMessage, - resolveWeeklyOpusUsageWindow -} from '../utils/usage'; -import { makeTimerProgressBar } from './shared/progress-bar'; -import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; +import { getUsagePercentCustomKeybinds } from './shared/usage-display'; import { - cycleUsageDisplayMode, - getUsageDisplayMode, - getUsageDisplayModifierText, - getUsagePercentCustomKeybinds, - getUsageProgressBarWidth, - isUsageCursorEnabled, - isUsageInverted, - isUsageProgressMode, - isUsageSliderMode, - makeSliderBar, - toggleUsageCursor, - toggleUsageInverted -} from './shared/usage-display'; - -const LABEL = 'Weekly Opus: '; + getUsagePercentWidgetDescription, + getUsagePercentWidgetDisplayName, + getUsagePercentWidgetEditorDisplay, + handleUsagePercentWidgetEditorAction, + renderUsagePercentWidgetValue +} from './shared/usage-percent-widget'; export class WeeklyOpusUsageWidget implements Widget { getDefaultColor(): string { return 'brightBlue'; } - getDescription(): string { return 'Shows weekly Opus API usage percentage'; } - getDisplayName(): string { return 'Weekly Opus Usage'; } + getDescription(): string { return getUsagePercentWidgetDescription('weekly-opus'); } + getDisplayName(): string { return getUsagePercentWidgetDisplayName('weekly-opus'); } getCategory(): string { return 'Usage'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - return { - displayText: this.getDisplayName(), - modifierText: getUsageDisplayModifierText(item, { showUsageDirection: true }) - }; + return getUsagePercentWidgetEditorDisplay('weekly-opus', item); } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - if (action === 'toggle-progress') { - return cycleUsageDisplayMode(item, [], true, true); - } - - if (action === 'toggle-invert') { - return toggleUsageInverted(item); - } - - if (action === 'toggle-cursor') { - return toggleUsageCursor(item); - } - - return null; + return handleUsagePercentWidgetEditorAction(action, item); } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { - const displayMode = getUsageDisplayMode(item); - const inverted = isUsageInverted(item); - const showCursor = isUsageCursorEnabled(item); - const format = resolveNumberFormat('percent', item, settings); - - 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}] ${formatPercent(renderedPercent, format)}`; - return formatRawOrLabeledValue(item, LABEL, progressDisplay); - } - - if (isUsageSliderMode(displayMode)) { - const slider = makeSliderBar(renderedPercent, undefined, showCursor ? { cursorPercent: 50 } : undefined); - const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(renderedPercent, format)}` : slider; - return formatRawOrLabeledValue(item, LABEL, sliderDisplay); - } - - return formatRawOrLabeledValue(item, LABEL, formatPercent(renderedPercent, format)); - } - - const data = context.usageData ?? {}; - if (data.weeklyOpusUsage === undefined) { - if (data.error) - return getUsageErrorMessage(data.error); - 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}] ${formatPercent(renderedPercent, format)}`; - return formatRawOrLabeledValue(item, LABEL, progressDisplay); - } - - if (isUsageSliderMode(displayMode)) { - const slider = makeSliderBar(renderedPercent, undefined, getCursorOptions()); - const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(renderedPercent, format)}` : slider; - return formatRawOrLabeledValue(item, LABEL, sliderDisplay); - } - - return formatRawOrLabeledValue(item, LABEL, formatPercent(renderedPercent, format)); + return renderUsagePercentWidgetValue('weekly-opus', item, context, settings); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { diff --git a/src/widgets/WeeklySonnetUsage.ts b/src/widgets/WeeklySonnetUsage.ts index 52a6b0c5..cda20919 100644 --- a/src/widgets/WeeklySonnetUsage.ts +++ b/src/widgets/WeeklySonnetUsage.ts @@ -6,122 +6,32 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; -import { - formatPercent, - resolveNumberFormat -} from '../utils/number-format'; -import { - getUsageErrorMessage, - resolveWeeklySonnetUsageWindow -} from '../utils/usage'; -import { makeTimerProgressBar } from './shared/progress-bar'; -import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; +import { getUsagePercentCustomKeybinds } from './shared/usage-display'; import { - cycleUsageDisplayMode, - getUsageDisplayMode, - getUsageDisplayModifierText, - getUsagePercentCustomKeybinds, - getUsageProgressBarWidth, - isUsageCursorEnabled, - isUsageInverted, - isUsageProgressMode, - isUsageSliderMode, - makeSliderBar, - toggleUsageCursor, - toggleUsageInverted -} from './shared/usage-display'; - -const LABEL = 'Weekly Sonnet: '; + getUsagePercentWidgetDescription, + getUsagePercentWidgetDisplayName, + getUsagePercentWidgetEditorDisplay, + handleUsagePercentWidgetEditorAction, + renderUsagePercentWidgetValue +} from './shared/usage-percent-widget'; export class WeeklySonnetUsageWidget implements Widget { getDefaultColor(): string { return 'brightBlue'; } - getDescription(): string { return 'Shows weekly Sonnet API usage percentage'; } - getDisplayName(): string { return 'Weekly Sonnet Usage'; } + getDescription(): string { return getUsagePercentWidgetDescription('weekly-sonnet'); } + getDisplayName(): string { return getUsagePercentWidgetDisplayName('weekly-sonnet'); } getCategory(): string { return 'Usage'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - return { - displayText: this.getDisplayName(), - modifierText: getUsageDisplayModifierText(item, { showUsageDirection: true }) - }; + return getUsagePercentWidgetEditorDisplay('weekly-sonnet', item); } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - if (action === 'toggle-progress') { - return cycleUsageDisplayMode(item, [], true, true); - } - - if (action === 'toggle-invert') { - return toggleUsageInverted(item); - } - - if (action === 'toggle-cursor') { - return toggleUsageCursor(item); - } - - return null; + return handleUsagePercentWidgetEditorAction(action, item); } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { - const displayMode = getUsageDisplayMode(item); - const inverted = isUsageInverted(item); - const showCursor = isUsageCursorEnabled(item); - const format = resolveNumberFormat('percent', item, settings); - - 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}] ${formatPercent(renderedPercent, format)}`; - return formatRawOrLabeledValue(item, LABEL, progressDisplay); - } - - if (isUsageSliderMode(displayMode)) { - const slider = makeSliderBar(renderedPercent, undefined, showCursor ? { cursorPercent: 50 } : undefined); - const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(renderedPercent, format)}` : slider; - return formatRawOrLabeledValue(item, LABEL, sliderDisplay); - } - - return formatRawOrLabeledValue(item, LABEL, formatPercent(renderedPercent, format)); - } - - const data = context.usageData ?? {}; - if (data.weeklySonnetUsage === undefined) { - if (data.error) - return getUsageErrorMessage(data.error); - 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}] ${formatPercent(renderedPercent, format)}`; - return formatRawOrLabeledValue(item, LABEL, progressDisplay); - } - - if (isUsageSliderMode(displayMode)) { - const slider = makeSliderBar(renderedPercent, undefined, getCursorOptions()); - const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(renderedPercent, format)}` : slider; - return formatRawOrLabeledValue(item, LABEL, sliderDisplay); - } - - return formatRawOrLabeledValue(item, LABEL, formatPercent(renderedPercent, format)); + return renderUsagePercentWidgetValue('weekly-sonnet', item, context, settings); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { diff --git a/src/widgets/WeeklyUsage.ts b/src/widgets/WeeklyUsage.ts index 0e41453d..ec4c2d25 100644 --- a/src/widgets/WeeklyUsage.ts +++ b/src/widgets/WeeklyUsage.ts @@ -6,120 +6,32 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; -import { - formatPercent, - resolveNumberFormat -} from '../utils/number-format'; -import { - getUsageErrorMessage, - resolveWeeklyUsageWindow -} from '../utils/usage'; -import { makeTimerProgressBar } from './shared/progress-bar'; -import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; +import { getUsagePercentCustomKeybinds } from './shared/usage-display'; import { - cycleUsageDisplayMode, - getUsageDisplayMode, - getUsageDisplayModifierText, - getUsagePercentCustomKeybinds, - getUsageProgressBarWidth, - isUsageCursorEnabled, - isUsageInverted, - isUsageProgressMode, - isUsageSliderMode, - makeSliderBar, - toggleUsageCursor, - toggleUsageInverted -} from './shared/usage-display'; + getUsagePercentWidgetDescription, + getUsagePercentWidgetDisplayName, + getUsagePercentWidgetEditorDisplay, + handleUsagePercentWidgetEditorAction, + renderUsagePercentWidgetValue +} from './shared/usage-percent-widget'; export class WeeklyUsageWidget implements Widget { getDefaultColor(): string { return 'brightBlue'; } - getDescription(): string { return 'Shows weekly API usage percentage'; } - getDisplayName(): string { return 'Weekly Usage'; } + getDescription(): string { return getUsagePercentWidgetDescription('weekly'); } + getDisplayName(): string { return getUsagePercentWidgetDisplayName('weekly'); } getCategory(): string { return 'Usage'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - return { - displayText: this.getDisplayName(), - modifierText: getUsageDisplayModifierText(item, { showUsageDirection: true }) - }; + return getUsagePercentWidgetEditorDisplay('weekly', item); } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { - if (action === 'toggle-progress') { - return cycleUsageDisplayMode(item, [], true, true); - } - - if (action === 'toggle-invert') { - return toggleUsageInverted(item); - } - - if (action === 'toggle-cursor') { - return toggleUsageCursor(item); - } - - return null; + return handleUsagePercentWidgetEditorAction(action, item); } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { - const displayMode = getUsageDisplayMode(item); - const inverted = isUsageInverted(item); - const showCursor = isUsageCursorEnabled(item); - const format = resolveNumberFormat('percent', item, settings); - - if (context.isPreview) { - const previewPercent = 12; - 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}] ${formatPercent(renderedPercent, format)}`; - return formatRawOrLabeledValue(item, 'Weekly: ', progressDisplay); - } - - if (isUsageSliderMode(displayMode)) { - const slider = makeSliderBar(renderedPercent, undefined, showCursor ? { cursorPercent: 50 } : undefined); - const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(renderedPercent, format)}` : slider; - return formatRawOrLabeledValue(item, 'Weekly: ', sliderDisplay); - } - - return formatRawOrLabeledValue(item, 'Weekly: ', formatPercent(renderedPercent, format)); - } - - const data = context.usageData ?? {}; - if (data.weeklyUsage === undefined) { - if (data.error) - return getUsageErrorMessage(data.error); - return null; - } - - const percent = Math.max(0, Math.min(100, data.weeklyUsage)); - const renderedPercent = inverted ? 100 - percent : percent; - const getCursorOptions = (): { cursorPercent: number } | undefined => { - if (!showCursor) { - return undefined; - } - - const window = resolveWeeklyUsageWindow(data); - return window ? { cursorPercent: window.elapsedPercent } : undefined; - }; - - if (isUsageProgressMode(displayMode)) { - const width = getUsageProgressBarWidth(displayMode); - - const progressBar = makeTimerProgressBar(renderedPercent, width, getCursorOptions()); - const progressDisplay = `[${progressBar}] ${formatPercent(renderedPercent, format)}`; - return formatRawOrLabeledValue(item, 'Weekly: ', progressDisplay); - } - - if (isUsageSliderMode(displayMode)) { - const slider = makeSliderBar(renderedPercent, undefined, getCursorOptions()); - const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(renderedPercent, format)}` : slider; - return formatRawOrLabeledValue(item, 'Weekly: ', sliderDisplay); - } - - return formatRawOrLabeledValue(item, 'Weekly: ', formatPercent(renderedPercent, format)); + return renderUsagePercentWidgetValue('weekly', item, context, settings); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { diff --git a/src/widgets/__tests__/FableWeeklyUsage.test.ts b/src/widgets/__tests__/FableWeeklyUsage.test.ts index dfb229e1..7f7dc381 100644 --- a/src/widgets/__tests__/FableWeeklyUsage.test.ts +++ b/src/widgets/__tests__/FableWeeklyUsage.test.ts @@ -55,12 +55,12 @@ describe('FableWeeklyUsageWidget', () => { id: 'fable-weekly', type: 'fable-weekly-usage', metadata: { cursor: 'true', display: 'slider' } - }, context)).toBe('Fable Weekly: ▓▓░░░│░░░░ 20.0%'); + }, context)).toBe('Weekly Fable: ▓▓░░░│░░░░ 20.0%'); expect(render(widget, { id: 'fable-weekly', type: 'fable-weekly-usage', metadata: { cursor: 'true', display: 'slider-only' } - }, context)).toBe('Fable Weekly: ▓▓░░░│░░░░'); + }, context)).toBe('Weekly Fable: ▓▓░░░│░░░░'); }); it('returns null when the per-model usage is missing from the API response', () => { @@ -72,14 +72,15 @@ describe('FableWeeklyUsageWidget', () => { baseItem: { id: 'fable-weekly', type: 'fable-weekly-usage' }, createWidget: () => new FableWeeklyUsageWidget(), errorMessageMock: usageErrorMessageMock, - expectedInvertedTime: 'Fable Weekly: 57.9%', + expectedInvertedTime: 'Weekly Fable: 57.9%', expectedModifierText: '(long bar, remaining)', - expectedPreviewInvertedTime: 'Fable Weekly: 96.0%', - expectedProgress: 'Fable Weekly: [███████████████████░░░░░░░░░░░░░] 57.9%', + expectedPreviewInvertedTime: 'Weekly Fable: 96.0%', + expectedProgress: 'Weekly Fable: [███████████████████░░░░░░░░░░░░░] 57.9%', expectedRawInvertedTime: '57.9%', expectedRawProgress: '[███████░░░░░░░░░] 42.1%', expectedRawTime: '42.1%', - expectedTime: 'Fable Weekly: 42.1%', + expectedTime: 'Weekly Fable: 42.1%', + expectedWholePercentTime: 'Weekly Fable: 42%', modifierItem: { id: 'fable-weekly', type: 'fable-weekly-usage', diff --git a/src/widgets/__tests__/SessionUsage.test.ts b/src/widgets/__tests__/SessionUsage.test.ts index 6d7b168b..bec6dc83 100644 --- a/src/widgets/__tests__/SessionUsage.test.ts +++ b/src/widgets/__tests__/SessionUsage.test.ts @@ -39,7 +39,6 @@ describe('SessionUsageWidget', () => { beforeEach(() => { vi.restoreAllMocks(); mockGetUsageErrorMessage = vi.spyOn(usage, 'getUsageErrorMessage'); - // makeUsageProgressBar no longer used; SessionUsage uses makeTimerProgressBar directly }); afterEach(() => { @@ -76,6 +75,7 @@ describe('SessionUsageWidget', () => { expectedRawProgress: '[████████░░░░░░░░░░░░░░░░░░░░░░░░] 23.4%', expectedRawTime: '23.4%', expectedTime: 'Session: 23.4%', + expectedWholePercentTime: 'Session: 23%', modifierItem: { id: 'session', type: 'session-usage', diff --git a/src/widgets/__tests__/WeeklyOpusUsage.test.ts b/src/widgets/__tests__/WeeklyOpusUsage.test.ts index 8c9d128f..ddd257c7 100644 --- a/src/widgets/__tests__/WeeklyOpusUsage.test.ts +++ b/src/widgets/__tests__/WeeklyOpusUsage.test.ts @@ -80,6 +80,7 @@ describe('WeeklyOpusUsageWidget', () => { expectedRawProgress: '[███████░░░░░░░░░] 42.1%', expectedRawTime: '42.1%', expectedTime: 'Weekly Opus: 42.1%', + expectedWholePercentTime: 'Weekly Opus: 42%', modifierItem: { id: 'weekly-opus', type: 'weekly-opus-usage', diff --git a/src/widgets/__tests__/WeeklySonnetUsage.test.ts b/src/widgets/__tests__/WeeklySonnetUsage.test.ts index ba94cb79..6c51c5b2 100644 --- a/src/widgets/__tests__/WeeklySonnetUsage.test.ts +++ b/src/widgets/__tests__/WeeklySonnetUsage.test.ts @@ -80,6 +80,7 @@ describe('WeeklySonnetUsageWidget', () => { expectedRawProgress: '[███████░░░░░░░░░] 42.1%', expectedRawTime: '42.1%', expectedTime: 'Weekly Sonnet: 42.1%', + expectedWholePercentTime: 'Weekly Sonnet: 42%', modifierItem: { id: 'weekly-sonnet', type: 'weekly-sonnet-usage', diff --git a/src/widgets/__tests__/WeeklyUsage.test.ts b/src/widgets/__tests__/WeeklyUsage.test.ts index 7bb76c68..ae3e3571 100644 --- a/src/widgets/__tests__/WeeklyUsage.test.ts +++ b/src/widgets/__tests__/WeeklyUsage.test.ts @@ -39,7 +39,6 @@ describe('WeeklyUsageWidget', () => { beforeEach(() => { vi.restoreAllMocks(); mockGetUsageErrorMessage = vi.spyOn(usage, 'getUsageErrorMessage'); - // makeUsageProgressBar no longer used; WeeklyUsage uses makeTimerProgressBar directly }); afterEach(() => { @@ -76,6 +75,7 @@ describe('WeeklyUsageWidget', () => { expectedRawProgress: '[███████░░░░░░░░░] 42.1%', expectedRawTime: '42.1%', expectedTime: 'Weekly: 42.1%', + expectedWholePercentTime: 'Weekly: 42%', modifierItem: { id: 'weekly', type: 'weekly-usage', diff --git a/src/widgets/__tests__/helpers/usage-widget-suites.ts b/src/widgets/__tests__/helpers/usage-widget-suites.ts index 9c31d05e..bcf862ca 100644 --- a/src/widgets/__tests__/helpers/usage-widget-suites.ts +++ b/src/widgets/__tests__/helpers/usage-widget-suites.ts @@ -31,6 +31,7 @@ interface UsagePercentWidgetSuiteConfig { expectedRawTime: string; expectedInvertedTime: string; expectedTime: string; + expectedWholePercentTime: string; modifierItem: WidgetItem; progressItem: WidgetItem; rawProgressItem: WidgetItem; @@ -150,6 +151,21 @@ export function runUsagePercentWidgetSuite(conf expect(config.render(widget, config.baseItem, context)).toBe(config.expectedTime); }); + // formatPercent's format argument is optional and its default reproduces the + // baseline output, so a render path that stops passing the resolved format + // stays invisible against default settings. Pinning a non-default style is + // what makes that reachable. + it('applies the resolved number format to the percentage', () => { + const widget = config.createWidget(); + const context = getUsageContext(config.usageField, config.usageValue); + const wholePercentItem: WidgetItem = { + ...config.baseItem, + numberFormat: { style: 'whole' } + }; + + expect(config.render(widget, wholePercentItem, context)).toBe(config.expectedWholePercentTime); + }); + it('renders inverted percentage in time mode', () => { const widget = config.createWidget(); const context = getUsageContext(config.usageField, config.usageValue); diff --git a/src/widgets/shared/usage-percent-widget.ts b/src/widgets/shared/usage-percent-widget.ts new file mode 100644 index 00000000..64beefdd --- /dev/null +++ b/src/widgets/shared/usage-percent-widget.ts @@ -0,0 +1,202 @@ +import type { NumberFormat } from '../../types/NumberFormat'; +import type { + RenderContext, + RenderUsageData +} from '../../types/RenderContext'; +import type { Settings } from '../../types/Settings'; +import type { + WidgetEditorDisplay, + WidgetItem +} from '../../types/Widget'; +import { + formatPercent, + resolveNumberFormat +} from '../../utils/number-format'; +import { + getUsageErrorMessage, + resolveFableUsageWindow, + resolveUsageWindowWithFallback, + resolveWeeklyOpusUsageWindow, + resolveWeeklySonnetUsageWindow, + resolveWeeklyUsageWindow +} from '../../utils/usage'; +import type { UsageWindowMetrics } from '../../utils/usage-types'; + +import { makeTimerProgressBar } from './progress-bar'; +import { formatRawOrLabeledValue } from './raw-or-labeled'; +import { + cycleUsageDisplayMode, + getUsageDisplayMode, + getUsageDisplayModifierText, + getUsageProgressBarWidth, + isUsageCursorEnabled, + isUsageInverted, + isUsageProgressMode, + isUsageSliderMode, + makeSliderBar, + toggleUsageCursor, + toggleUsageInverted +} from './usage-display'; + +export type UsagePercentWidgetKind = 'session' | 'weekly' | 'weekly-sonnet' | 'weekly-opus' | 'fable-weekly'; + +type UsagePercentField = 'sessionUsage' | 'weeklyUsage' | 'weeklySonnetUsage' | 'weeklyOpusUsage' | 'fableUsage'; + +interface UsageCursorOptions { cursorPercent: number } + +interface UsagePercentWidgetKindConfig { + label: string; + displayName: string; + description: string; + previewPercent: number; + usageField: UsagePercentField; +} + +const USAGE_PERCENT_WIDGET_CONFIG: Record = { + 'session': { + label: 'Session: ', + displayName: 'Session Usage', + description: 'Shows daily/session API usage percentage', + previewPercent: 20, + usageField: 'sessionUsage' + }, + 'weekly': { + label: 'Weekly: ', + displayName: 'Weekly Usage', + description: 'Shows weekly API usage percentage', + previewPercent: 12, + usageField: 'weeklyUsage' + }, + 'weekly-sonnet': { + label: 'Weekly Sonnet: ', + displayName: 'Weekly Sonnet Usage', + description: 'Shows weekly Sonnet API usage percentage', + previewPercent: 8, + usageField: 'weeklySonnetUsage' + }, + 'weekly-opus': { + label: 'Weekly Opus: ', + displayName: 'Weekly Opus Usage', + description: 'Shows weekly Opus API usage percentage', + previewPercent: 4, + usageField: 'weeklyOpusUsage' + }, + 'fable-weekly': { + label: 'Weekly Fable: ', + displayName: 'Weekly Fable Usage', + description: 'Shows Fable-only weekly usage percentage', + previewPercent: 4, + usageField: 'fableUsage' + } +}; + +// The session window also consults block metrics, so the resolvers take different +// arguments and are picked per call rather than stored alongside the config. +function resolveUsageWindow(kind: UsagePercentWidgetKind, data: RenderUsageData, context: RenderContext): UsageWindowMetrics | null { + if (kind === 'session') { + return resolveUsageWindowWithFallback(data, context.blockMetrics); + } + if (kind === 'weekly') { + return resolveWeeklyUsageWindow(data); + } + if (kind === 'weekly-sonnet') { + return resolveWeeklySonnetUsageWindow(data); + } + if (kind === 'weekly-opus') { + return resolveWeeklyOpusUsageWindow(data); + } + return resolveFableUsageWindow(data); +} + +function renderUsageDisplay( + item: WidgetItem, + label: string, + percent: number, + format: NumberFormat, + getCursorOptions: () => UsageCursorOptions | undefined +): string { + const displayMode = getUsageDisplayMode(item); + + if (isUsageProgressMode(displayMode)) { + const width = getUsageProgressBarWidth(displayMode); + const progressBar = makeTimerProgressBar(percent, width, getCursorOptions()); + const progressDisplay = `[${progressBar}] ${formatPercent(percent, format)}`; + return formatRawOrLabeledValue(item, label, progressDisplay); + } + + if (isUsageSliderMode(displayMode)) { + const slider = makeSliderBar(percent, undefined, getCursorOptions()); + const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(percent, format)}` : slider; + return formatRawOrLabeledValue(item, label, sliderDisplay); + } + + return formatRawOrLabeledValue(item, label, formatPercent(percent, format)); +} + +export function getUsagePercentWidgetDisplayName(kind: UsagePercentWidgetKind): string { + return USAGE_PERCENT_WIDGET_CONFIG[kind].displayName; +} + +export function getUsagePercentWidgetDescription(kind: UsagePercentWidgetKind): string { + return USAGE_PERCENT_WIDGET_CONFIG[kind].description; +} + +export function getUsagePercentWidgetEditorDisplay(kind: UsagePercentWidgetKind, item: WidgetItem): WidgetEditorDisplay { + return { + displayText: getUsagePercentWidgetDisplayName(kind), + modifierText: getUsageDisplayModifierText(item, { showUsageDirection: true }) + }; +} + +export function handleUsagePercentWidgetEditorAction(action: string, item: WidgetItem): WidgetItem | null { + if (action === 'toggle-progress') { + return cycleUsageDisplayMode(item, [], true, true); + } + + if (action === 'toggle-invert') { + return toggleUsageInverted(item); + } + + if (action === 'toggle-cursor') { + return toggleUsageCursor(item); + } + + return null; +} + +export function renderUsagePercentWidgetValue( + kind: UsagePercentWidgetKind, + item: WidgetItem, + context: RenderContext, + settings: Settings +): string | null { + const config = USAGE_PERCENT_WIDGET_CONFIG[kind]; + const inverted = isUsageInverted(item); + const showCursor = isUsageCursorEnabled(item); + const format = resolveNumberFormat('percent', item, settings); + + if (context.isPreview) { + const renderedPercent = inverted ? 100 - config.previewPercent : config.previewPercent; + return renderUsageDisplay(item, config.label, renderedPercent, format, () => showCursor ? { cursorPercent: 50 } : undefined); + } + + const data: RenderUsageData = context.usageData ?? {}; + const usagePercent = data[config.usageField]; + if (usagePercent === undefined) { + if (data.error) + return getUsageErrorMessage(data.error); + return null; + } + + const percent = Math.max(0, Math.min(100, usagePercent)); + const renderedPercent = inverted ? 100 - percent : percent; + + return renderUsageDisplay(item, config.label, renderedPercent, format, () => { + if (!showCursor) { + return undefined; + } + + const window = resolveUsageWindow(kind, data, context); + return window ? { cursorPercent: window.elapsedPercent } : undefined; + }); +}