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 e8b38396..4df9afe3 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 type { Settings } from '../../types/Settings'; import { COLOR_MAP, @@ -18,6 +25,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; @@ -33,6 +68,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); @@ -159,6 +196,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(); @@ -208,6 +258,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; @@ -235,6 +288,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); @@ -359,6 +440,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 efa2a31c..17c7e0f1 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'; @@ -68,6 +69,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 121c2a9b..254e299a 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 692b2694..c91c0a81 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, @@ -97,6 +101,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; @@ -104,13 +109,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); } @@ -144,15 +149,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 5b6467be..b74b4b27 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, @@ -102,12 +104,12 @@ function toggleHideZero(item: WidgetItem): WidgetItem { }; } -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 { @@ -124,13 +126,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; } @@ -225,18 +227,18 @@ export class CompactionCounterWidget implements Widget { } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { - void settings; + const format = resolveNumberFormat('token', item, settings); const icon = isNerdFontEnabled(item) ? COMPACTION_NERD_FONT_ICON : COMPACTION_ICON; if (context.isPreview) { - return formatStats(SAMPLE_STATS, item, icon); + return formatStats(SAMPLE_STATS, item, icon, format); } const data = context.compactionData ?? ZERO_COMPACTION_STATS; if (data.count === 0 && isHideZeroEnabled(item)) return null; - 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 71dc3780..013fe205 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 { @@ -62,6 +66,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; @@ -70,16 +75,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: ', `${previewPercent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, 'Overage: ', formatPercent(previewPercent, format)); } const data = context.usageData ?? {}; @@ -101,16 +106,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: ', `${percent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, 'Overage: ', formatPercent(percent, 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 0a554e6e..f05a2942 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: ', `${previewPercent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, 'Session: ', formatPercent(previewPercent, 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: ', `${percent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, 'Session: ', formatPercent(percent, 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 0049e04c..a1b4f5af 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, `${previewPercent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, LABEL, formatPercent(previewPercent, 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, `${percent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, LABEL, formatPercent(percent, format)); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { diff --git a/src/widgets/WeeklyResetTimer.ts b/src/widgets/WeeklyResetTimer.ts index 2ef74058..ac9adb88 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, @@ -176,6 +180,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; @@ -183,13 +188,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); } @@ -228,15 +233,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 56be3a3e..2fdff8dd 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, `${previewPercent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, LABEL, formatPercent(previewPercent, 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, `${percent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, LABEL, formatPercent(percent, format)); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { diff --git a/src/widgets/WeeklyUsage.ts b/src/widgets/WeeklyUsage.ts index a9fc928c..126d3d47 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: ', `${previewPercent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, 'Weekly: ', formatPercent(previewPercent, 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: ', `${percent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, 'Weekly: ', formatPercent(percent, 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[] {