diff --git a/src/tui/components/GlobalOverridesMenu.tsx b/src/tui/components/GlobalOverridesMenu.tsx index facfd283..cd86a015 100644 --- a/src/tui/components/GlobalOverridesMenu.tsx +++ b/src/tui/components/GlobalOverridesMenu.tsx @@ -6,6 +6,12 @@ import { import React, { useState } from 'react'; import { getColorLevelString } from '../../types/ColorLevel'; +import { + NUMBER_KINDS, + type GlobalNumberFormat, + type NumberFormat, + type NumberKind +} from '../../types/NumberFormat'; import { DefaultPaddingSideSchema, type Settings @@ -18,9 +24,37 @@ import { } from '../../utils/colors'; import { GRADIENT_PRESET_NAMES } from '../../utils/gradient'; import { shouldInsertInput } from '../../utils/input-guards'; +import { getNextNumberStyle } from '../../utils/number-format'; import { ConfirmDialog } from './ConfirmDialog'; +const NUMBER_FORMAT_KIND_WIDTH = Math.max(...NUMBER_KINDS.map(kind => kind.length)); + +// 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 nextStyle = getNextNumberStyle(current); + + 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 +70,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 +198,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 +260,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 +300,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.padStart(NUMBER_FORMAT_KIND_WIDTH)} + {': '} + {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 +452,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/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index c631aaec..470ee148 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -14,6 +14,10 @@ import type { } from '../../types/Widget'; import { getBackgroundColorsForPowerline } from '../../utils/colors'; import { generateGuid } from '../../utils/guid'; +import { + getNumberFormatKeybind, + getNumberFormatModifierText +} from '../../utils/number-format'; import { canDetectTerminalWidth } from '../../utils/terminal'; import { filterWidgetCatalog, @@ -112,6 +116,13 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB const getCustomKeybindsForWidget = (widgetImpl: Widget, widget: WidgetItem): CustomKeybind[] => { const keybinds = widgetImpl.getCustomKeybinds ? [...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. + if (widgetImpl.supportsNumberFormat?.()) { + keybinds.push(getNumberFormatKeybind()); + } + // Widgets declaring hideable states share a single (h)ide… keybind // that opens the hide-state checklist instead of per-widget toggles. // Such widgets must leave 'h' unbound: keybind matching takes the @@ -567,6 +578,9 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB const widgetImpl = widget.type !== 'separator' && widget.type !== 'flex-separator' ? getWidget(widget.type) : null; const { displayText, modifierText } = widgetImpl?.getEditorDisplay(widget) ?? { displayText: getWidgetDisplay(widget) }; const supportsRawValue = widgetImpl?.supportsRawValue() ?? false; + const numberFormatModifierText = widgetImpl?.supportsNumberFormat?.() + ? getNumberFormatModifierText(widget) + : undefined; const hideModifierText = widgetImpl ? getHideModifierText(widget, widgetImpl.getHideableStates?.() ?? []) : undefined; return ( @@ -585,6 +599,12 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB {modifierText} )} + {numberFormatModifierText && ( + + {' '} + {numberFormatModifierText} + + )} {hideModifierText && ( {' '} diff --git a/src/tui/components/__tests__/GlobalOverridesMenu.test.ts b/src/tui/components/__tests__/GlobalOverridesMenu.test.ts index 46c0d37a..36e4b344 100644 --- a/src/tui/components/__tests__/GlobalOverridesMenu.test.ts +++ b/src/tui/components/__tests__/GlobalOverridesMenu.test.ts @@ -1,6 +1,7 @@ import { render } from 'ink'; import { PassThrough } from 'node:stream'; import React from 'react'; +import stripAnsi from 'strip-ansi'; import { afterEach, describe, @@ -104,6 +105,49 @@ describe('GlobalOverridesMenu', () => { } }); + it('right-aligns number kind labels on their colons', async () => { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + + const instance = render( + React.createElement(GlobalOverridesMenu, { + settings: DEFAULT_SETTINGS, + onUpdate: vi.fn(), + onBack: vi.fn() + }), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + try { + await flushInk(); + stdout.clearOutput(); + stdin.write('n'); + await flushInk(); + + const numberRows = stripAnsi(stdout.getOutput()) + .split('\n') + .filter(line => /(?:token|speed|percent|memory|cost): precise/.test(line)); + const colonColumns = new Set(numberRows.map(line => line.indexOf(':'))); + + expect(numberRows).toHaveLength(5); + expect(colonColumns.size).toBe(1); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); + it('toggles minimalist mode on when (m) is pressed', async () => { const stdin = createMockStdin(); const stdout = createMockStdout(); diff --git a/src/tui/components/__tests__/ItemsEditor.test.ts b/src/tui/components/__tests__/ItemsEditor.test.ts new file mode 100644 index 00000000..93546a99 --- /dev/null +++ b/src/tui/components/__tests__/ItemsEditor.test.ts @@ -0,0 +1,166 @@ +import { render } from 'ink'; +import { PassThrough } from 'node:stream'; +import React, { useState } from 'react'; +import stripAnsi from 'strip-ansi'; +import { + describe, + expect, + it, + vi +} from 'vitest'; + +import { DEFAULT_SETTINGS } from '../../../types/Settings'; +import type { WidgetItem } from '../../../types/Widget'; +import { ItemsEditor } from '../ItemsEditor'; + +class MockTtyStream extends PassThrough { + isTTY = true; + columns = 120; + rows = 40; + + setRawMode() { + return this; + } + + ref() { + return this; + } + + unref() { + return this; + } +} + +interface CapturedWriteStream extends NodeJS.WriteStream { + clearOutput: () => void; + getOutput: () => string; +} + +function createMockStdin(): NodeJS.ReadStream { + return new MockTtyStream() as unknown as NodeJS.ReadStream; +} + +function createMockStdout(): CapturedWriteStream { + const stream = new MockTtyStream(); + const chunks: string[] = []; + + stream.on('data', (chunk: Buffer | string) => { + chunks.push(chunk.toString()); + }); + + return Object.assign(stream as unknown as NodeJS.WriteStream, { + clearOutput() { + chunks.length = 0; + }, + getOutput() { + return chunks.join(''); + } + }); +} + +function flushInk() { + return new Promise((resolve) => { + setTimeout(resolve, 25); + }); +} + +function StatefulItemsEditor({ initialWidgets }: { initialWidgets: WidgetItem[] }) { + const [widgets, setWidgets] = useState(initialWidgets); + + return React.createElement(ItemsEditor, { + widgets, + onUpdate: setWidgets, + onBack: vi.fn(), + lineNumber: 1, + settings: DEFAULT_SETTINGS + }); +} + +describe('ItemsEditor', () => { + it('shows only non-default number styles beside the widget name', async () => { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + + const instance = render( + React.createElement(StatefulItemsEditor, { initialWidgets: [{ id: '1', type: 'tokens-input' }] }), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + try { + await flushInk(); + expect(stripAnsi(stdout.getOutput())).toContain('1. Tokens Input'); + expect(stripAnsi(stdout.getOutput())).not.toContain('(compact)'); + + stdout.clearOutput(); + stdin.write('.'); + await flushInk(); + expect(stripAnsi(stdout.getOutput())).toContain('1. Tokens Input (compact)'); + + stdout.clearOutput(); + stdin.write('.'); + await flushInk(); + expect(stripAnsi(stdout.getOutput())).toContain('1. Tokens Input (whole)'); + + stdout.clearOutput(); + stdin.write('.'); + await flushInk(); + expect(stripAnsi(stdout.getOutput())).toContain('1. Tokens Input'); + expect(stripAnsi(stdout.getOutput())).not.toContain('(compact)'); + expect(stripAnsi(stdout.getOutput())).not.toContain('(whole)'); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); + + it('preserves existing widget modifiers before the number style', async () => { + const stdin = createMockStdin(); + const stdout = createMockStdout(); + const stderr = createMockStdout(); + + const instance = render( + React.createElement(ItemsEditor, { + widgets: [{ + id: '1', + type: 'cache-read', + metadata: { cacheScopeSession: 'true' }, + numberFormat: { style: 'compact' } + }], + onUpdate: vi.fn(), + onBack: vi.fn(), + lineNumber: 1, + settings: DEFAULT_SETTINGS + }), + { + stdin, + stdout, + stderr, + debug: true, + exitOnCtrlC: false, + patchConsole: false + } + ); + + try { + await flushInk(); + expect(stripAnsi(stdout.getOutput())).toContain('1. Cache Read (session) (compact)'); + } finally { + instance.unmount(); + instance.cleanup(); + stdin.destroy(); + stdout.destroy(); + stderr.destroy(); + } + }); +}); diff --git a/src/tui/components/color-menu/__tests__/mutations.test.ts b/src/tui/components/color-menu/__tests__/mutations.test.ts index d4b7e300..2a506f9b 100644 --- a/src/tui/components/color-menu/__tests__/mutations.test.ts +++ b/src/tui/components/color-menu/__tests__/mutations.test.ts @@ -58,7 +58,7 @@ describe('color-menu mutations', () => { expect(whole[1]?.dim).toBeUndefined(); }); - it('resetWidgetStyling removes color, backgroundColor, bold, and dim from one widget', () => { + it('resetWidgetStyling removes color, backgroundColor, bold, dim, and numberFormat from one widget', () => { const widgets: WidgetItem[] = [ { id: '1', @@ -66,7 +66,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 +88,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..ce13a3b9 100644 --- a/src/tui/components/color-menu/mutations.ts +++ b/src/tui/components/color-menu/mutations.ts @@ -67,12 +67,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 +86,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/tui/components/items-editor/__tests__/input-handlers.test.ts b/src/tui/components/items-editor/__tests__/input-handlers.test.ts index 1f880fb1..8a216035 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 { EDIT_HIDE_STATES_ACTION, @@ -1041,4 +1046,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 c5c2507e..2a309ade 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, @@ -487,13 +491,19 @@ export function handleNormalInputMode({ if (matchedKeybind && !key.ctrl) { // The hide-state checklist is rendered by the items editor for // every widget that declares hideable states, so it bypasses - // widget-level action handling + // widget-level action handling. if (matchedKeybind.action === EDIT_HIDE_STATES_ACTION) { setCustomEditorWidget({ widget: currentWidget, impl: widgetImpl, action: matchedKeybind.action }); return; } - 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/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 4bfb0ccd..ac920d37 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 752100b6..80f38d86 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(), @@ -54,6 +56,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 new file mode 100644 index 00000000..0430bd0c --- /dev/null +++ b/src/utils/__tests__/number-format.test.ts @@ -0,0 +1,210 @@ +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 { + CYCLE_NUMBER_STYLE_ACTION, + cycleNumberStyle, + formatCost, + formatPercent, + getNextNumberStyle, + getNumberFormatKeybind, + getNumberFormatModifierText, + 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('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('treats an explicit precise style as the default state', () => { + const item: WidgetItem = { + id: '1', + type: 'tokens-input', + numberFormat: { style: 'precise', decimals: 2 } + }; + + expect(cycleNumberStyle(item).numberFormat).toEqual({ style: 'compact', decimals: 2 }); + expect(getNextNumberStyle('precise')).toBe('compact'); + }); + + 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('getNumberFormatModifierText', () => { + const item = (style?: 'precise' | 'compact' | 'whole'): WidgetItem => ({ + id: '1', + type: 'tokens-input', + ...(style ? { numberFormat: { style } } : {}) + }); + + it('omits the default number style', () => { + expect(getNumberFormatModifierText(item())).toBeUndefined(); + expect(getNumberFormatModifierText(item('precise'))).toBeUndefined(); + }); + + it('labels non-default number styles', () => { + expect(getNumberFormatModifierText(item('compact'))).toBe('(compact)'); + expect(getNumberFormatModifierText(item('whole'))).toBe('(whole)'); + }); +}); + +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..2041bb60 --- /dev/null +++ b/src/utils/number-format.ts @@ -0,0 +1,103 @@ +import type { + NumberFormat, + NumberKind, + NumberStyle +} from '../types/NumberFormat'; +import type { Settings } from '../types/Settings'; +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; +} + +export function getNumberFormatModifierText(item: WidgetItem): string | undefined { + const style = item.numberFormat?.style; + return style === 'compact' || style === 'whole' ? `(${style})` : undefined; +} + +export function getNextNumberStyle(currentStyle: NumberStyle | undefined): NumberStyle | undefined { + const normalizedStyle = currentStyle === 'precise' ? undefined : currentStyle; + + return normalizedStyle === undefined + ? 'compact' + : normalizedStyle === 'compact' + ? 'whole' + : undefined; +} + +// Cycle the number style: default (precise) -> compact -> whole -> default. +// Any explicit `decimals` is preserved across the cycle. +export function cycleNumberStyle(item: WidgetItem): WidgetItem { + const nextStyle = getNextNumberStyle(item.numberFormat?.style); + 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. +// "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 192d36dd..736601f1 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, @@ -92,6 +96,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; @@ -99,13 +104,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); } @@ -139,15 +144,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); } @@ -188,4 +192,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 4e2f7440..59beaace 100644 --- a/src/widgets/BlockTimer.ts +++ b/src/widgets/BlockTimer.ts @@ -7,6 +7,10 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { + formatPercent, + resolveNumberFormat +} from '../utils/number-format'; import { formatUsageDuration, resolveUsageWindowWithFallback @@ -65,6 +69,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; @@ -72,13 +77,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); } @@ -94,16 +99,17 @@ export class BlockTimerWidget implements Widget { return null; } + const emptyPercent = formatPercent(0, format); if (isUsageProgressMode(displayMode)) { const barWidth = getUsageProgressBarWidth(displayMode); const emptyBar = '░'.repeat(barWidth); - return formatRawOrLabeledValue(item, 'Block ', `[${emptyBar}] 0.0%`); + return formatRawOrLabeledValue(item, 'Block ', `[${emptyBar}] ${emptyPercent}`); } if (isUsageSliderMode(displayMode)) { const emptySlider = makeSliderBar(0); const sliderDisplay = displayMode === 'slider' - ? `${emptySlider} 0.0%` + ? `${emptySlider} ${emptyPercent}` : emptySlider; return formatRawOrLabeledValue(item, 'Block ', sliderDisplay); } @@ -115,15 +121,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); } @@ -142,4 +147,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 e78a5646..c7b7fc52 100644 --- a/src/widgets/CacheHitRate.ts +++ b/src/widgets/CacheHitRate.ts @@ -7,6 +7,10 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { + formatPercent, + resolveNumberFormat +} from '../utils/number-format'; import { getCacheHitRate, @@ -40,8 +44,9 @@ export class CacheHitRateWidget implements Widget { } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + const format = resolveNumberFormat('percent', item, settings); if (context.isPreview) { - return formatRawOrLabeledValue(item, 'Cache Hit: ', '87.0%'); + return formatRawOrLabeledValue(item, 'Cache Hit: ', formatPercent(87, format)); } const hideWhenEmpty = isHidden(item, CACHE_EMPTY_HIDEABLE_STATE.key); @@ -52,14 +57,14 @@ export class CacheHitRateWidget implements Widget { const hitRate = getCacheHitRate(tokens); if (hitRate === null) { - return hideWhenEmpty ? null : formatRawOrLabeledValue(item, 'Cache Hit: ', '0.0%'); + return hideWhenEmpty ? null : formatRawOrLabeledValue(item, 'Cache Hit: ', formatPercent(0, format)); } if (hitRate === 0 && hideWhenEmpty) { return null; } - return formatRawOrLabeledValue(item, 'Cache Hit: ', `${hitRate.toFixed(1)}%`); + return formatRawOrLabeledValue(item, 'Cache Hit: ', formatPercent(hitRate, format)); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { @@ -68,4 +73,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 3ccf61c2..0ac0a6c4 100644 --- a/src/widgets/CacheRead.ts +++ b/src/widgets/CacheRead.ts @@ -7,6 +7,7 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { resolveNumberFormat } from '../utils/number-format'; import { formatTokensWithPercentage, @@ -41,8 +42,11 @@ export class CacheReadWidget implements Widget { } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + const tokenFormat = resolveNumberFormat('token', item, settings); + const percentFormat = resolveNumberFormat('percent', item, settings); if (context.isPreview) { - return formatRawOrLabeledValue(item, 'Cache Read: ', '12k (64.0%)'); + const value = formatTokensWithPercentage(12000, 64, tokenFormat, percentFormat); + return formatRawOrLabeledValue(item, 'Cache Read: ', value); } const hideWhenEmpty = isHidden(item, CACHE_EMPTY_HIDEABLE_STATE.key); @@ -55,7 +59,7 @@ export class CacheReadWidget implements Widget { return null; } - const value = formatTokensWithPercentage(tokens.read, getCacheReadPercentage(tokens)); + const value = formatTokensWithPercentage(tokens.read, getCacheReadPercentage(tokens), tokenFormat, percentFormat); return formatRawOrLabeledValue(item, 'Cache Read: ', value); } @@ -65,4 +69,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 58fc76fd..f85b9b9c 100644 --- a/src/widgets/CacheWrite.ts +++ b/src/widgets/CacheWrite.ts @@ -7,6 +7,7 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { resolveNumberFormat } from '../utils/number-format'; import { formatTokensWithPercentage, @@ -41,8 +42,11 @@ export class CacheWriteWidget implements Widget { } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + const tokenFormat = resolveNumberFormat('token', item, settings); + const percentFormat = resolveNumberFormat('percent', item, settings); if (context.isPreview) { - return formatRawOrLabeledValue(item, 'Cache Write: ', '3k (16.0%)'); + const value = formatTokensWithPercentage(3000, 16, tokenFormat, percentFormat); + return formatRawOrLabeledValue(item, 'Cache Write: ', value); } const hideWhenEmpty = isHidden(item, CACHE_EMPTY_HIDEABLE_STATE.key); @@ -55,7 +59,7 @@ export class CacheWriteWidget implements Widget { return null; } - const value = formatTokensWithPercentage(tokens.creation, getCacheWritePercentage(tokens)); + const value = formatTokensWithPercentage(tokens.creation, getCacheWritePercentage(tokens), tokenFormat, percentFormat); return formatRawOrLabeledValue(item, 'Cache Write: ', value); } @@ -65,4 +69,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 1828c9fb..41b21481 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 @@ -13,6 +14,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 { isHidden } from './shared/hideable'; import { @@ -109,12 +111,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 { @@ -131,13 +133,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; } @@ -227,7 +229,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); @@ -236,7 +238,7 @@ export class CompactionCounterWidget implements Widget { if (value === 0 && isHidden(item, ZERO_HIDEABLE_STATE.key) && !context.isPreview) { return null; } - return metric === 'reclaimed' ? formatTokens(value) : String(value); + return metric === 'reclaimed' ? formatTokens(value, format) : String(value); } if (data.count === 0 && isHidden(item, ZERO_HIDEABLE_STATE.key) && !context.isPreview) { @@ -244,7 +246,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[] { @@ -276,4 +278,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 400bc54d..16e42fce 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,15 +83,20 @@ 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) { + const usedDisplay = formatTokens(50000, tokenFormat, 0); + const totalDisplay = formatTokens(200000, tokenFormat, 0); + const percentDisplay = formatPercent(25, percentFormat, 0); if (isBarSliderMode(displayMode)) { const slider = makeSliderBar(25); - const sliderDisplay = displayMode === 'slider' ? `${slider} 50k/200k (25%)` : slider; + const sliderDisplay = displayMode === 'slider' ? `${slider} ${usedDisplay}/${totalDisplay} (${percentDisplay})` : slider; return item.rawValue ? sliderDisplay : `Context: ${sliderDisplay}`; } const barWidth = displayMode === 'progress' ? 32 : 16; - const previewDisplay = `${makeUsageProgressBar(25, barWidth)} 50k/200k (25%)`; + const previewDisplay = `${makeUsageProgressBar(25, barWidth)} ${usedDisplay}/${totalDisplay} (${percentDisplay})`; return item.rawValue ? previewDisplay : `Context: ${previewDisplay}`; } @@ -111,17 +120,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}`; } @@ -134,4 +144,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 85c9c312..969cb5c6 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,21 +19,24 @@ 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 value = formatTokens(18600, format); + return item.rawValue ? value : `Ctx: ${value}`; } 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; } 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 f6940106..371a0136 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) { @@ -77,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 9dd8c679..9e8c65b3 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) { @@ -89,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 3c2b102f..f40ff2dc 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,8 +23,10 @@ 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'; + const value = formatTokens(200000, format); + return item.rawValue ? value : `Win: ${value}`; } let total = getContextWindowSize(context.data); @@ -37,9 +40,10 @@ 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; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/ExtraUsageRemaining.ts b/src/widgets/ExtraUsageRemaining.ts index ff71f048..838ab59c 100644 --- a/src/widgets/ExtraUsageRemaining.ts +++ b/src/widgets/ExtraUsageRemaining.ts @@ -6,6 +6,7 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { resolveNumberFormat } from '../utils/number-format'; import { getUsageErrorMessage } from '../utils/usage'; import { formatUsageCurrency } from './shared/currency'; @@ -16,7 +17,7 @@ import { USAGE_NO_DATA_HIDEABLE_STATE } from './shared/usage-display'; export class ExtraUsageRemainingWidget implements Widget { getDefaultColor(): string { return 'green'; } - getDescription(): string { return 'Shows remaining USD of your monthly extra usage limit'; } + getDescription(): string { return 'Shows the remaining amount of your monthly extra usage limit'; } getDisplayName(): string { return 'Extra Usage Remaining'; } getCategory(): string { return 'Usage'; } @@ -29,8 +30,9 @@ export class ExtraUsageRemainingWidget implements Widget { } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + const format = resolveNumberFormat('cost', item, settings); if (context.isPreview) { - return formatRawOrLabeledValue(item, 'Overage Left: ', '$3,894.00'); + return formatRawOrLabeledValue(item, 'Overage Left: ', formatUsageCurrency(3894, undefined, format)); } const data = context.usageData ?? {}; @@ -52,11 +54,12 @@ export class ExtraUsageRemainingWidget implements Widget { const limitDollars = data.extraUsageLimit / 100; const usedDollars = data.extraUsageUsed / 100; const remaining = Math.max(0, limitDollars - usedDollars); - const formatted = formatUsageCurrency(remaining, data.extraUsageCurrency); + const formatted = formatUsageCurrency(remaining, data.extraUsageCurrency, format); return formatRawOrLabeledValue(item, 'Overage Left: ', formatted); } supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/ExtraUsageUsed.ts b/src/widgets/ExtraUsageUsed.ts index 1f8a48ba..8faaba7f 100644 --- a/src/widgets/ExtraUsageUsed.ts +++ b/src/widgets/ExtraUsageUsed.ts @@ -6,6 +6,7 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { resolveNumberFormat } from '../utils/number-format'; import { getUsageErrorMessage } from '../utils/usage'; import { formatUsageCurrency } from './shared/currency'; @@ -16,7 +17,7 @@ import { USAGE_NO_DATA_HIDEABLE_STATE } from './shared/usage-display'; export class ExtraUsageUsedWidget implements Widget { getDefaultColor(): string { return 'green'; } - getDescription(): string { return 'Shows USD spent on extra usage (pay-as-you-go overage)'; } + getDescription(): string { return 'Shows amount spent on extra usage (pay-as-you-go overage)'; } getDisplayName(): string { return 'Extra Usage Used'; } getCategory(): string { return 'Usage'; } @@ -29,8 +30,9 @@ export class ExtraUsageUsedWidget implements Widget { } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + const format = resolveNumberFormat('cost', item, settings); if (context.isPreview) { - return formatRawOrLabeledValue(item, 'Overage Used: ', '$106.00'); + return formatRawOrLabeledValue(item, 'Overage Used: ', formatUsageCurrency(106, undefined, format)); } const data = context.usageData ?? {}; @@ -50,11 +52,12 @@ export class ExtraUsageUsedWidget implements Widget { // extraUsageUsed is in cents const usedDollars = data.extraUsageUsed / 100; - const formatted = formatUsageCurrency(usedDollars, data.extraUsageCurrency); + const formatted = formatUsageCurrency(usedDollars, data.extraUsageCurrency, format); return formatRawOrLabeledValue(item, 'Overage Used: ', formatted); } 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 4733b3bf..851b7c65 100644 --- a/src/widgets/ExtraUsageUtilization.ts +++ b/src/widgets/ExtraUsageUtilization.ts @@ -7,6 +7,10 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { + formatPercent, + resolveNumberFormat +} from '../utils/number-format'; import { getUsageErrorMessage } from '../utils/usage'; import { EXTRA_USAGE_DISABLED_HIDEABLE_STATE } from './shared/extra-usage-disabled'; @@ -59,6 +63,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; @@ -67,16 +72,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 ?? {}; @@ -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: ', `${renderedPercent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, 'Overage: ', formatPercent(renderedPercent, format)); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { @@ -119,4 +124,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 821ed405..54aa7912 100644 --- a/src/widgets/FableWeeklyUsage.ts +++ b/src/widgets/FableWeeklyUsage.ts @@ -7,6 +7,10 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { + formatPercent, + resolveNumberFormat +} from '../utils/number-format'; import { getUsageErrorMessage, resolveFableUsageWindow @@ -67,6 +71,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); @@ -78,17 +83,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 ?? {}; @@ -116,17 +121,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[] { @@ -135,4 +140,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 4e82d7c2..6aa4485d 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,8 +76,10 @@ 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'; + const value = `${formatBytes(12.4 * 1024 ** 3, format)}/${formatBytes(16 * 1024 ** 3, format)}`; + return item.rawValue ? value : `Mem: ${value}`; } const total = os.totalmem(); @@ -86,11 +93,12 @@ 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}`; } 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 a71f46d4..af255020 100644 --- a/src/widgets/InputSpeed.ts +++ b/src/widgets/InputSpeed.ts @@ -29,8 +29,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[] { @@ -47,4 +46,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 1f00452f..228eba71 100644 --- a/src/widgets/OutputSpeed.ts +++ b/src/widgets/OutputSpeed.ts @@ -29,8 +29,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[] { @@ -47,4 +46,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 71274078..07e336f0 100644 --- a/src/widgets/SessionCost.ts +++ b/src/widgets/SessionCost.ts @@ -6,6 +6,10 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { + formatCost, + resolveNumberFormat +} from '../utils/number-format'; import { isHidden } from './shared/hideable'; @@ -25,8 +29,10 @@ 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'; + const value = formatCost(2.45, format); + return item.rawValue ? value : `Cost: ${value}`; } const totalCost = context.data?.cost?.total_cost_usd; @@ -34,16 +40,18 @@ export class SessionCostWidget implements Widget { return null; } - // Format the cost to 2 decimal places - const formattedCost = `$${totalCost.toFixed(2)}`; - - if (formattedCost === '$0.00' && isHidden(item, ZERO_HIDEABLE_STATE.key)) { + // Keep the zero-state threshold tied to the baseline cent precision, + // independent of the selected display style or decimal override. + const roundsToZeroCents = totalCost >= 0 && totalCost < 0.005; + if (roundsToZeroCents && isHidden(item, ZERO_HIDEABLE_STATE.key)) { return null; } + const formattedCost = formatCost(totalCost, format); return item.rawValue ? formattedCost : `Cost: ${formattedCost}`; } 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 a92a7b30..09ceee40 100644 --- a/src/widgets/SessionUsage.ts +++ b/src/widgets/SessionUsage.ts @@ -7,6 +7,10 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { + formatPercent, + resolveNumberFormat +} from '../utils/number-format'; import { getUsageErrorMessage, resolveUsageWindowWithFallback @@ -68,6 +72,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; @@ -76,17 +81,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 ?? {}; @@ -114,17 +119,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[] { @@ -133,4 +138,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 68ed6ea4..237ac27b 100644 --- a/src/widgets/TokensCached.ts +++ b/src/widgets/TokensCached.ts @@ -6,6 +6,7 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { resolveNumberFormat } from '../utils/number-format'; import { formatTokens } from '../utils/renderer'; import { isHidden } from './shared/hideable'; @@ -27,19 +28,21 @@ 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'); + return formatRawOrLabeledValue(item, 'Cached: ', formatTokens(12000, format)); } if (context.tokenMetrics) { if (context.tokenMetrics.cachedTokens === 0 && isHidden(item, ZERO_HIDEABLE_STATE.key)) { return null; } - return formatRawOrLabeledValue(item, 'Cached: ', formatTokens(context.tokenMetrics.cachedTokens)); + return formatRawOrLabeledValue(item, 'Cached: ', formatTokens(context.tokenMetrics.cachedTokens, format)); } return null; } 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 19b04af3..1266deac 100644 --- a/src/widgets/TokensInput.ts +++ b/src/widgets/TokensInput.ts @@ -7,6 +7,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 { isHidden } from './shared/hideable'; @@ -28,8 +29,9 @@ 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'); + return formatRawOrLabeledValue(item, 'In: ', formatTokens(15200, format)); } const inputTotalTokens = context.tokenMetrics?.inputTokens @@ -43,9 +45,10 @@ export class TokensInputWidget implements Widget { return null; } - return formatRawOrLabeledValue(item, 'In: ', formatTokens(inputTotalTokens)); + return formatRawOrLabeledValue(item, 'In: ', formatTokens(inputTotalTokens, format)); } 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 d80a9775..43dc46d4 100644 --- a/src/widgets/TokensOutput.ts +++ b/src/widgets/TokensOutput.ts @@ -7,6 +7,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 { isHidden } from './shared/hideable'; @@ -28,8 +29,9 @@ 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'); + return formatRawOrLabeledValue(item, 'Out: ', formatTokens(3400, format)); } const outputTotalTokens = context.tokenMetrics?.outputTokens @@ -43,9 +45,10 @@ export class TokensOutputWidget implements Widget { return null; } - return formatRawOrLabeledValue(item, 'Out: ', formatTokens(outputTotalTokens)); + return formatRawOrLabeledValue(item, 'Out: ', formatTokens(outputTotalTokens, format)); } 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 7fe41b0a..a780ad31 100644 --- a/src/widgets/TokensTotal.ts +++ b/src/widgets/TokensTotal.ts @@ -6,6 +6,7 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { resolveNumberFormat } from '../utils/number-format'; import { formatTokens } from '../utils/renderer'; import { isHidden } from './shared/hideable'; @@ -27,19 +28,21 @@ 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'); + return formatRawOrLabeledValue(item, 'Total: ', formatTokens(30600, format)); } if (context.tokenMetrics) { if (context.tokenMetrics.totalTokens === 0 && isHidden(item, ZERO_HIDEABLE_STATE.key)) { return null; } - return formatRawOrLabeledValue(item, 'Total: ', formatTokens(context.tokenMetrics.totalTokens)); + return formatRawOrLabeledValue(item, 'Total: ', formatTokens(context.tokenMetrics.totalTokens, format)); } return null; } 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 004d66e1..e0bd1d32 100644 --- a/src/widgets/TotalSpeed.ts +++ b/src/widgets/TotalSpeed.ts @@ -29,8 +29,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[] { @@ -47,4 +46,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 e1ce58b7..1cec4273 100644 --- a/src/widgets/WeeklyOpusUsage.ts +++ b/src/widgets/WeeklyOpusUsage.ts @@ -7,6 +7,10 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { + formatPercent, + resolveNumberFormat +} from '../utils/number-format'; import { getUsageErrorMessage, resolveWeeklyOpusUsageWindow @@ -70,6 +74,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; @@ -78,17 +83,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 ?? {}; @@ -116,17 +121,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[] { @@ -135,4 +140,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 1f456a5c..1c1f670c 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, @@ -171,6 +175,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; @@ -178,13 +183,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); } @@ -223,15 +228,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); } @@ -281,4 +285,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 e4a64318..e35bc215 100644 --- a/src/widgets/WeeklySonnetUsage.ts +++ b/src/widgets/WeeklySonnetUsage.ts @@ -7,6 +7,10 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { + formatPercent, + resolveNumberFormat +} from '../utils/number-format'; import { getUsageErrorMessage, resolveWeeklySonnetUsageWindow @@ -70,6 +74,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; @@ -78,17 +83,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 ?? {}; @@ -116,17 +121,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[] { @@ -135,4 +140,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 67d991b1..2269502f 100644 --- a/src/widgets/WeeklyUsage.ts +++ b/src/widgets/WeeklyUsage.ts @@ -7,6 +7,10 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { + formatPercent, + resolveNumberFormat +} from '../utils/number-format'; import { getUsageErrorMessage, resolveWeeklyUsageWindow @@ -68,6 +72,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; @@ -76,17 +81,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 ?? {}; @@ -114,17 +119,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[] { @@ -133,4 +138,5 @@ export class WeeklyUsageWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/__tests__/BlockTimer.test.ts b/src/widgets/__tests__/BlockTimer.test.ts index 3dd34898..a0a8b13c 100644 --- a/src/widgets/__tests__/BlockTimer.test.ts +++ b/src/widgets/__tests__/BlockTimer.test.ts @@ -103,6 +103,12 @@ describe('BlockTimerWidget', () => { type: 'block-timer', metadata: { display: 'progress' } }, { usageData: { error: 'timeout' } })).toBe(`Block [${'░'.repeat(32)}] 0.0%`); + expect(render(widget, { + id: 'block', + type: 'block-timer', + metadata: { display: 'progress' }, + numberFormat: { style: 'compact' } + }, { usageData: { error: 'timeout' } })).toBe(`Block [${'░'.repeat(32)}] 0%`); }); it('hides empty values when the no-data hide state is enabled', () => { @@ -205,6 +211,12 @@ describe('BlockTimerWidget', () => { type: 'block-timer', metadata: { display: 'slider' } }, { usageData: { error: 'timeout' } })).toBe('Block ░░░░░░░░░░ 0.0%'); + expect(render(widget, { + id: 'block', + type: 'block-timer', + metadata: { display: 'slider' }, + numberFormat: { style: 'whole' } + }, { usageData: { error: 'timeout' } })).toBe('Block ░░░░░░░░░░ 0%'); expect(render(widget, { id: 'block', type: 'block-timer', diff --git a/src/widgets/__tests__/CacheWidgets.test.ts b/src/widgets/__tests__/CacheWidgets.test.ts index f2e42580..09eda872 100644 --- a/src/widgets/__tests__/CacheWidgets.test.ts +++ b/src/widgets/__tests__/CacheWidgets.test.ts @@ -154,6 +154,7 @@ describe('Cache widgets', () => { // Token widgets drop percentages when there is no prompt-context denominator. expect(new w.CacheHitRateWidget().render(turnItem('cache-hit-rate'), context, DEFAULT_SETTINGS)).toBe('Cache Hit: 0.0%'); + expect(new w.CacheHitRateWidget().render(turnItem('cache-hit-rate', { numberFormat: { style: 'whole' } }), context, DEFAULT_SETTINGS)).toBe('Cache Hit: 0%'); expect(new w.CacheReadWidget().render(turnItem('cache-read'), context, DEFAULT_SETTINGS)).toBe('Cache Read: fmt:0'); expect(new w.CacheWriteWidget().render(turnItem('cache-write'), context, DEFAULT_SETTINGS)).toBe('Cache Write: fmt:0'); }); @@ -218,7 +219,20 @@ describe('Cache widgets', () => { const context: RenderContext = { isPreview: true }; expect(new w.CacheHitRateWidget().render(turnItem('cache-hit-rate'), context, DEFAULT_SETTINGS)).toBe('Cache Hit: 87.0%'); - expect(new w.CacheReadWidget().render(turnItem('cache-read', { rawValue: true }), context, DEFAULT_SETTINGS)).toBe('12k (64.0%)'); - expect(new w.CacheWriteWidget().render(turnItem('cache-write'), context, DEFAULT_SETTINGS)).toBe('Cache Write: 3k (16.0%)'); + expect(new w.CacheReadWidget().render(turnItem('cache-read', { rawValue: true }), context, DEFAULT_SETTINGS)).toBe('fmt:12000 (64.0%)'); + expect(new w.CacheWriteWidget().render(turnItem('cache-write'), context, DEFAULT_SETTINGS)).toBe('Cache Write: fmt:3000 (16.0%)'); + }); + + it('formats every preview sample with the selected styles', async () => { + const w = await loadWidgets(); + const context: RenderContext = { isPreview: true }; + const numberFormat = { style: 'whole' as const }; + + expect(new w.CacheHitRateWidget().render(turnItem('cache-hit-rate', { numberFormat }), context, DEFAULT_SETTINGS)).toBe('Cache Hit: 87%'); + expect(new w.CacheReadWidget().render(turnItem('cache-read', { numberFormat }), context, DEFAULT_SETTINGS)).toBe('Cache Read: fmt:12000 (64%)'); + expect(new w.CacheWriteWidget().render(turnItem('cache-write', { numberFormat }), context, DEFAULT_SETTINGS)).toBe('Cache Write: fmt:3000 (16%)'); + + expect(renderer.formatTokens).toHaveBeenNthCalledWith(1, 12000, numberFormat); + expect(renderer.formatTokens).toHaveBeenNthCalledWith(2, 3000, numberFormat); }); }); diff --git a/src/widgets/__tests__/ContextBar.test.ts b/src/widgets/__tests__/ContextBar.test.ts index f78cc3b5..f8d09ad4 100644 --- a/src/widgets/__tests__/ContextBar.test.ts +++ b/src/widgets/__tests__/ContextBar.test.ts @@ -12,6 +12,8 @@ import { DEFAULT_SETTINGS } from '../../types/Settings'; import type { WidgetItem } from '../../types/Widget'; import * as usage from '../../utils/usage'; import { ContextBarWidget } from '../ContextBar'; +import { ContextLengthWidget } from '../ContextLength'; +import { ContextWindowWidget } from '../ContextWindow'; describe('ContextBarWidget', () => { beforeEach(() => { @@ -165,4 +167,24 @@ describe('ContextBarWidget', () => { expect(third?.metadata?.display).toBe('slider-only'); expect(fourth?.metadata?.display).toBe('progress-short'); }); + + it('formats context preview samples with the selected styles', () => { + const context: RenderContext = { isPreview: true }; + + expect(new ContextLengthWidget().render({ + id: 'length', + type: 'context-length', + numberFormat: { style: 'whole' } + }, context, DEFAULT_SETTINGS)).toBe('Ctx: 19k'); + expect(new ContextWindowWidget().render({ + id: 'window', + type: 'context-window', + numberFormat: { decimals: 2 } + }, context, DEFAULT_SETTINGS)).toBe('Win: 200.00k'); + expect(new ContextBarWidget().render({ + id: 'bar', + type: 'context-bar', + numberFormat: { decimals: 2 } + }, context, DEFAULT_SETTINGS)).toBe('Context: [bar:25.0:16] 50.00k/200.00k (25.00%)'); + }); }); diff --git a/src/widgets/__tests__/ExtraUsageRemaining.test.ts b/src/widgets/__tests__/ExtraUsageRemaining.test.ts index a9bc306f..aa11d36a 100644 --- a/src/widgets/__tests__/ExtraUsageRemaining.test.ts +++ b/src/widgets/__tests__/ExtraUsageRemaining.test.ts @@ -60,6 +60,32 @@ describe('ExtraUsageRemainingWidget', () => { })).toBe('Overage Left: €3,894.00'); }); + it('applies the global cost style while preserving the reported currency', () => { + const widget = new ExtraUsageRemainingWidget(); + const settings = { + ...DEFAULT_SETTINGS, + numberFormat: { cost: { style: 'compact' as const } } + }; + const context: RenderContext = { + usageData: { + extraUsageCurrency: 'EUR', + extraUsageEnabled: true, + extraUsageLimit: 400000, + extraUsageUsed: 10600 + } + }; + + expect(widget.render({ + id: 'extra', + type: 'extra-usage-remaining' + }, context, settings)).toBe('Overage Left: €3,894'); + expect(widget.render({ + id: 'extra', + type: 'extra-usage-remaining' + }, { isPreview: true }, settings)).toBe('Overage Left: $3,894'); + expect(widget.supportsNumberFormat()).toBe(true); + }); + it('clamps remaining budget at zero', () => { const widget = new ExtraUsageRemainingWidget(); diff --git a/src/widgets/__tests__/ExtraUsageUsed.test.ts b/src/widgets/__tests__/ExtraUsageUsed.test.ts index 03481593..70be2b52 100644 --- a/src/widgets/__tests__/ExtraUsageUsed.test.ts +++ b/src/widgets/__tests__/ExtraUsageUsed.test.ts @@ -69,6 +69,32 @@ describe('ExtraUsageUsedWidget', () => { })).toBe('Overage Used: €5.42'); }); + it('applies the global cost style while preserving the reported currency', () => { + const widget = new ExtraUsageUsedWidget(); + const settings = { + ...DEFAULT_SETTINGS, + numberFormat: { cost: { style: 'whole' as const } } + }; + const context: RenderContext = { + usageData: { + extraUsageCurrency: 'EUR', + extraUsageEnabled: true, + extraUsageUsed: 542 + } + }; + + expect(widget.render({ + id: 'extra', + type: 'extra-usage-used', + numberFormat: { decimals: 3 } + }, context, settings)).toBe('Overage Used: €5'); + expect(widget.render({ + id: 'extra', + type: 'extra-usage-used' + }, { isPreview: true }, settings)).toBe('Overage Used: $106'); + expect(widget.supportsNumberFormat()).toBe(true); + }); + it('declares the disabled and no-data hideable states', () => { const widget = new ExtraUsageUsedWidget(); diff --git a/src/widgets/__tests__/FreeMemory.test.ts b/src/widgets/__tests__/FreeMemory.test.ts index d62ba83b..21ec555f 100644 --- a/src/widgets/__tests__/FreeMemory.test.ts +++ b/src/widgets/__tests__/FreeMemory.test.ts @@ -79,6 +79,17 @@ describe('FreeMemoryWidget', () => { expect(result).toBe('12.4G/16.0G'); }); + + it('should format mock data with the selected memory style', () => { + const context: RenderContext = { isPreview: true }; + const item: WidgetItem = { + id: 'mem', + type: 'free-memory', + numberFormat: { style: 'whole' } + }; + + expect(widget.render(item, context, DEFAULT_SETTINGS)).toBe('Mem: 12G/16G'); + }); }); describe('render on macOS (vm_stat)', () => { diff --git a/src/widgets/__tests__/SessionCost.test.ts b/src/widgets/__tests__/SessionCost.test.ts index 695e0389..f5bfd386 100644 --- a/src/widgets/__tests__/SessionCost.test.ts +++ b/src/widgets/__tests__/SessionCost.test.ts @@ -28,6 +28,14 @@ describe('SessionCostWidget', () => { expect(render({ id: 'session-cost', type: 'session-cost' }, {})).toBeNull(); }); + it('formats the preview sample with the selected cost style', () => { + expect(render({ + id: 'session-cost', + type: 'session-cost', + numberFormat: { style: 'whole' } + }, { isPreview: true })).toBe('Cost: $2'); + }); + it('declares the zero hideable state', () => { expect(new SessionCostWidget().getHideableStates().map(state => state.key)).toEqual(['zero']); }); @@ -55,4 +63,17 @@ describe('SessionCostWidget', () => { metadata: { hide: 'zero' } }, { data: { cost: { total_cost_usd: 0.001 } } })).toBeNull(); }); + + it.each([ + { name: 'compact', numberFormat: { style: 'compact' as const } }, + { name: 'whole', numberFormat: { style: 'whole' as const } }, + { name: 'custom-decimal', numberFormat: { decimals: 4 } } + ])('preserves zero hiding with the $name number format', ({ numberFormat }) => { + expect(render({ + id: 'session-cost', + type: 'session-cost', + metadata: { hide: 'zero' }, + numberFormat + }, { data: { cost: { total_cost_usd: 0 } } })).toBeNull(); + }); }); diff --git a/src/widgets/__tests__/SpeedWidgets.test.ts b/src/widgets/__tests__/SpeedWidgets.test.ts index 18c3c0d2..118820ea 100644 --- a/src/widgets/__tests__/SpeedWidgets.test.ts +++ b/src/widgets/__tests__/SpeedWidgets.test.ts @@ -30,13 +30,15 @@ function createItem( options: { rawValue?: boolean; metadata?: Record; + numberFormat?: WidgetItem['numberFormat']; } = {} ): WidgetItem { return { id: type, type, rawValue: options.rawValue, - metadata: options.metadata + metadata: options.metadata, + numberFormat: options.numberFormat }; } @@ -67,6 +69,13 @@ describe('OutputSpeedWidget', () => { expect(widget.render(createItem('output-speed'), context, DEFAULT_SETTINGS)).toBe('Out: 42.5 t/s'); }); + it('should format the preview value with the selected speed style', () => { + const context: RenderContext = { isPreview: true }; + const item = createItem('output-speed', { numberFormat: { style: 'whole' } }); + + expect(widget.render(item, context, DEFAULT_SETTINGS)).toBe('Out: 43 t/s'); + }); + it('should render window preview when window metadata is enabled', () => { const context: RenderContext = { isPreview: true }; const item = createItem('output-speed', { metadata: { windowSeconds: '45' } }); @@ -100,6 +109,19 @@ describe('speed widget hideable states', () => { expect(widget.render(createItem('output-speed'), context, DEFAULT_SETTINGS)).toBe('Out: —'); expect(widget.render(createItem('output-speed', { metadata: { hide: 'no-data' } }), context, DEFAULT_SETTINGS)).toBeNull(); }); + + it('applies number formatting without bypassing no-data hiding', () => { + const widget = new OutputSpeedWidget(); + const numberFormat = { style: 'compact' as const }; + const speedContext: RenderContext = { speedMetrics: createSpeedMetrics({ outputTokens: 500, totalDurationMs: 10000 }) }; + const noDataContext: RenderContext = { speedMetrics: createSpeedMetrics({ totalDurationMs: 0 }) }; + + expect(widget.render(createItem('output-speed', { numberFormat }), speedContext, DEFAULT_SETTINGS)).toBe('Out: 50 t/s'); + expect(widget.render(createItem('output-speed', { + metadata: { hide: 'no-data' }, + numberFormat + }), noDataContext, DEFAULT_SETTINGS)).toBeNull(); + }); }); describe('InputSpeedWidget', () => { diff --git a/src/widgets/__tests__/TokensWidgets.test.ts b/src/widgets/__tests__/TokensWidgets.test.ts index bdfde1cd..019f1a0c 100644 --- a/src/widgets/__tests__/TokensWidgets.test.ts +++ b/src/widgets/__tests__/TokensWidgets.test.ts @@ -157,6 +157,46 @@ describe('Token widgets', () => { expect(new TokensTotalWidget().render({ id: 'total', type: 'tokens-total', metadata: { hide: 'zero' } }, context, DEFAULT_SETTINGS)).toBeNull(); }); + it('applies number formatting without bypassing zero hiding', async () => { + const { TokensCachedWidget, TokensInputWidget, TokensOutputWidget, TokensTotalWidget } = await loadWidgets(); + const context: RenderContext = { + tokenMetrics: { + inputTokens: 1000, + outputTokens: 2000, + cachedTokens: 3000, + totalTokens: 6000, + contextLength: 0 + } + }; + const numberFormat = { style: 'compact' as const }; + + new TokensInputWidget().render({ id: 'in', type: 'tokens-input', numberFormat }, context, DEFAULT_SETTINGS); + new TokensOutputWidget().render({ id: 'out', type: 'tokens-output', numberFormat }, context, DEFAULT_SETTINGS); + new TokensCachedWidget().render({ id: 'cached', type: 'tokens-cached', numberFormat }, context, DEFAULT_SETTINGS); + new TokensTotalWidget().render({ id: 'total', type: 'tokens-total', numberFormat }, context, DEFAULT_SETTINGS); + + expect(renderer.formatTokens).toHaveBeenNthCalledWith(1, 1000, numberFormat); + expect(renderer.formatTokens).toHaveBeenNthCalledWith(2, 2000, numberFormat); + expect(renderer.formatTokens).toHaveBeenNthCalledWith(3, 3000, numberFormat); + expect(renderer.formatTokens).toHaveBeenNthCalledWith(4, 6000, numberFormat); + + const zeroContext: RenderContext = { + tokenMetrics: { + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + totalTokens: 0, + contextLength: 0 + } + }; + expect(new TokensInputWidget().render({ + id: 'hidden', + type: 'tokens-input', + numberFormat, + metadata: { hide: 'zero' } + }, zeroContext, DEFAULT_SETTINGS)).toBeNull(); + }); + it('declares the zero hideable state for all token widgets', async () => { const { TokensCachedWidget, TokensInputWidget, TokensOutputWidget, TokensTotalWidget } = await loadWidgets(); @@ -169,13 +209,29 @@ describe('Token widgets', () => { const { TokensCachedWidget, TokensInputWidget, TokensOutputWidget, TokensTotalWidget } = await loadWidgets(); const context: RenderContext = { isPreview: true }; - expect(new TokensInputWidget().render({ id: 'in', type: 'tokens-input' }, context, DEFAULT_SETTINGS)).toBe('In: 15.2k'); - expect(new TokensInputWidget().render({ id: 'in', type: 'tokens-input', rawValue: true }, context, DEFAULT_SETTINGS)).toBe('15.2k'); - expect(new TokensOutputWidget().render({ id: 'out', type: 'tokens-output' }, context, DEFAULT_SETTINGS)).toBe('Out: 3.4k'); - expect(new TokensOutputWidget().render({ id: 'out', type: 'tokens-output', rawValue: true }, context, DEFAULT_SETTINGS)).toBe('3.4k'); - expect(new TokensCachedWidget().render({ id: 'cached', type: 'tokens-cached' }, context, DEFAULT_SETTINGS)).toBe('Cached: 12k'); - expect(new TokensCachedWidget().render({ id: 'cached', type: 'tokens-cached', rawValue: true }, context, DEFAULT_SETTINGS)).toBe('12k'); - expect(new TokensTotalWidget().render({ id: 'total', type: 'tokens-total' }, context, DEFAULT_SETTINGS)).toBe('Total: 30.6k'); - expect(new TokensTotalWidget().render({ id: 'total', type: 'tokens-total', rawValue: true }, context, DEFAULT_SETTINGS)).toBe('30.6k'); + expect(new TokensInputWidget().render({ id: 'in', type: 'tokens-input' }, context, DEFAULT_SETTINGS)).toBe('In: fmt:15200'); + expect(new TokensInputWidget().render({ id: 'in', type: 'tokens-input', rawValue: true }, context, DEFAULT_SETTINGS)).toBe('fmt:15200'); + expect(new TokensOutputWidget().render({ id: 'out', type: 'tokens-output' }, context, DEFAULT_SETTINGS)).toBe('Out: fmt:3400'); + expect(new TokensOutputWidget().render({ id: 'out', type: 'tokens-output', rawValue: true }, context, DEFAULT_SETTINGS)).toBe('fmt:3400'); + expect(new TokensCachedWidget().render({ id: 'cached', type: 'tokens-cached' }, context, DEFAULT_SETTINGS)).toBe('Cached: fmt:12000'); + expect(new TokensCachedWidget().render({ id: 'cached', type: 'tokens-cached', rawValue: true }, context, DEFAULT_SETTINGS)).toBe('fmt:12000'); + expect(new TokensTotalWidget().render({ id: 'total', type: 'tokens-total' }, context, DEFAULT_SETTINGS)).toBe('Total: fmt:30600'); + expect(new TokensTotalWidget().render({ id: 'total', type: 'tokens-total', rawValue: true }, context, DEFAULT_SETTINGS)).toBe('fmt:30600'); + }); + + it('formats every preview sample with the selected token style', async () => { + const { TokensCachedWidget, TokensInputWidget, TokensOutputWidget, TokensTotalWidget } = await loadWidgets(); + const context: RenderContext = { isPreview: true }; + const numberFormat = { style: 'whole' as const }; + + new TokensInputWidget().render({ id: 'in', type: 'tokens-input', numberFormat }, context, DEFAULT_SETTINGS); + new TokensOutputWidget().render({ id: 'out', type: 'tokens-output', numberFormat }, context, DEFAULT_SETTINGS); + new TokensCachedWidget().render({ id: 'cached', type: 'tokens-cached', numberFormat }, context, DEFAULT_SETTINGS); + new TokensTotalWidget().render({ id: 'total', type: 'tokens-total', numberFormat }, context, DEFAULT_SETTINGS); + + expect(renderer.formatTokens).toHaveBeenNthCalledWith(1, 15200, numberFormat); + expect(renderer.formatTokens).toHaveBeenNthCalledWith(2, 3400, numberFormat); + expect(renderer.formatTokens).toHaveBeenNthCalledWith(3, 12000, numberFormat); + expect(renderer.formatTokens).toHaveBeenNthCalledWith(4, 30600, numberFormat); }); }); diff --git a/src/widgets/__tests__/shared/currency.test.ts b/src/widgets/__tests__/shared/currency.test.ts index 08184b6c..8a052629 100644 --- a/src/widgets/__tests__/shared/currency.test.ts +++ b/src/widgets/__tests__/shared/currency.test.ts @@ -20,4 +20,10 @@ describe('formatUsageCurrency', () => { it('falls back to USD for invalid currency codes', () => { expect(formatUsageCurrency(3894, 'not-a-currency')).toBe('$3,894.00'); }); + + it('applies number styles without losing currency formatting', () => { + expect(formatUsageCurrency(3894, 'EUR', { style: 'compact' })).toBe('€3,894'); + expect(formatUsageCurrency(5.42, 'GBP', { style: 'whole' })).toBe('£5'); + expect(formatUsageCurrency(5.42, 'USD', { decimals: 3 })).toBe('$5.420'); + }); }); 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/currency.ts b/src/widgets/shared/currency.ts index 50072d0b..8adfc4c5 100644 --- a/src/widgets/shared/currency.ts +++ b/src/widgets/shared/currency.ts @@ -1,23 +1,31 @@ +import type { NumberFormat } from '../../types/NumberFormat'; +import { effectiveDecimals } from '../../utils/number-format'; + const FALLBACK_CURRENCY = 'USD'; /** * Formats a monetary amount using the ISO 4217 currency code reported by the * usage API (extra_usage.currency), falling back to USD when absent or invalid. */ -export function formatUsageCurrency(amount: number, currency: string | undefined): string { +export function formatUsageCurrency( + amount: number, + currency: string | undefined, + format: NumberFormat = {} +): string { + const fractionDigits = effectiveDecimals(format, 2); + const options: Intl.NumberFormatOptions = { + style: 'currency', + currency: currency ?? FALLBACK_CURRENCY, + minimumFractionDigits: format.style === 'compact' ? 0 : fractionDigits, + maximumFractionDigits: fractionDigits + }; + try { - return amount.toLocaleString('en-US', { - style: 'currency', - currency: currency ?? FALLBACK_CURRENCY, - minimumFractionDigits: 2, - maximumFractionDigits: 2 - }); + return amount.toLocaleString('en-US', options); } catch { return amount.toLocaleString('en-US', { - style: 'currency', - currency: FALLBACK_CURRENCY, - minimumFractionDigits: 2, - maximumFractionDigits: 2 + ...options, + currency: FALLBACK_CURRENCY }); } } diff --git a/src/widgets/shared/speed-widget.tsx b/src/widgets/shared/speed-widget.tsx index 9a63b2c3..9373e3e4 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, @@ -15,6 +16,7 @@ import type { WidgetItem } from '../../types/Widget'; import { shouldInsertInput } from '../../utils/input-guards'; +import { resolveNumberFormat } from '../../utils/number-format'; import { calculateInputSpeed, calculateOutputSpeed, @@ -44,8 +46,8 @@ interface SpeedWidgetKindConfig { label: string; displayName: string; description: string; - sessionPreview: string; - windowedPreview: string; + sessionPreview: number; + windowedPreview: number; } const SPEED_WIDGET_CONFIG: Record = { @@ -53,22 +55,22 @@ const SPEED_WIDGET_CONFIG: Record = { label: 'In: ', displayName: 'Input Speed', description: 'Shows session-average input token speed (tokens/sec). Optional window: 0-120 seconds (0 = full-session average).', - sessionPreview: '85.2 t/s', - windowedPreview: '31.5 t/s' + sessionPreview: 85.2, + windowedPreview: 31.5 }, output: { label: 'Out: ', displayName: 'Output Speed', description: 'Shows session-average output token speed (tokens/sec). Optional window: 0-120 seconds (0 = full-session average).', - sessionPreview: '42.5 t/s', - windowedPreview: '26.8 t/s' + sessionPreview: 42.5, + windowedPreview: 26.8 }, total: { label: 'Total: ', displayName: 'Total Speed', description: 'Shows session-average total token speed (tokens/sec). Optional window: 0-120 seconds (0 = full-session average).', - sessionPreview: '127.7 t/s', - windowedPreview: '58.3 t/s' + sessionPreview: 127.7, + windowedPreview: 58.3 } }; @@ -114,15 +116,15 @@ 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) { - return formatRawOrLabeledValue(item, config.label, previewValue); + const previewValue = isWidgetSpeedWindowEnabled(item) ? config.windowedPreview : config.sessionPreview; + return formatRawOrLabeledValue(item, config.label, formatSpeed(previewValue, format)); } const metrics = getSpeedMetricsForWidget(item, context); @@ -135,7 +137,7 @@ export function renderSpeedWidgetValue( return null; } - return formatRawOrLabeledValue(item, config.label, formatSpeed(speed)); + return formatRawOrLabeledValue(item, config.label, formatSpeed(speed, format)); } export function getSpeedWidgetHideableStates(): HideableState[] {