From c6d4067f225ee726ca248343afa135cd30a81678 Mon Sep 17 00:00:00 2001 From: Zach Date: Tue, 16 Jun 2026 00:53:41 -0500 Subject: [PATCH 1/9] feat(number-format): configurable per-widget numeric precision Add a per-widget `numberFormat` ({ style: precise|compact|whole, decimals }) and an optional per-kind global (Settings.numberFormat), resolved by resolveNumberFormat(kind, item, settings) with a set global winning over the per-widget value (matching overrideForegroundColor / globalBold). Every numeric formatter routes through renderMagnitude with a type-appropriate baseline, so default output is unchanged: tokens/speed/percent stay 1 decimal, cost 2, memory G1/M0/K0. Styles: precise keeps trailing zeros (1.0M, today's default); compact trims them (1M, 1.1M); whole drops decimals (1M). Threaded through every numeric widget (tokens, context length/window/bar, compaction reclaimed, cache read/write/hit, free memory, session cost, speeds, context %, usage sliders, block/weekly timers). TUI: ColorMenu (n) cycles a widget's style, Global Overrides has a per-type submenu, and decimals is set in settings.json. Optional schema fields, so no settings migration. Co-Authored-By: Claude --- src/tui/components/ColorMenu.tsx | 16 ++- src/tui/components/GlobalOverridesMenu.tsx | 87 ++++++++++++ .../color-menu/__tests__/mutations.test.ts | 38 +++++- src/tui/components/color-menu/mutations.ts | 40 ++++++ src/types/NumberFormat.ts | 33 +++++ src/types/Settings.ts | 2 + src/types/Widget.ts | 2 + src/utils/__tests__/number-format.test.ts | 128 ++++++++++++++++++ .../__tests__/renderer-format-tokens.test.ts | 8 +- src/utils/format-tokens.ts | 27 ++-- src/utils/number-format.ts | 47 +++++++ src/utils/speed-metrics.ts | 11 +- src/widgets/BlockResetTimer.ts | 14 +- src/widgets/BlockTimer.ts | 14 +- src/widgets/CacheHitRate.ts | 7 +- src/widgets/CacheRead.ts | 5 +- src/widgets/CacheWrite.ts | 5 +- src/widgets/CompactionCounter.ts | 16 ++- src/widgets/ContextBar.ts | 15 +- src/widgets/ContextLength.ts | 6 +- src/widgets/ContextPercentage.ts | 9 +- src/widgets/ContextPercentageUsable.ts | 9 +- src/widgets/ContextWindow.ts | 4 +- src/widgets/ExtraUsageUtilization.ts | 17 ++- src/widgets/FreeMemory.ts | 16 ++- src/widgets/InputSpeed.ts | 3 +- src/widgets/OutputSpeed.ts | 3 +- src/widgets/SessionCost.ts | 8 +- src/widgets/SessionUsage.ts | 17 ++- src/widgets/TokensCached.ts | 4 +- src/widgets/TokensInput.ts | 6 +- src/widgets/TokensOutput.ts | 6 +- src/widgets/TokensTotal.ts | 4 +- src/widgets/TotalSpeed.ts | 3 +- src/widgets/WeeklyOpusUsage.ts | 17 ++- src/widgets/WeeklyResetTimer.ts | 14 +- src/widgets/WeeklySonnetUsage.ts | 17 ++- src/widgets/WeeklyUsage.ts | 17 ++- src/widgets/shared/cache-metrics.ts | 13 +- src/widgets/shared/context-slider.ts | 6 +- src/widgets/shared/speed-widget.tsx | 12 +- 41 files changed, 609 insertions(+), 117 deletions(-) create mode 100644 src/types/NumberFormat.ts create mode 100644 src/utils/__tests__/number-format.test.ts create mode 100644 src/utils/number-format.ts diff --git a/src/tui/components/ColorMenu.tsx b/src/tui/components/ColorMenu.tsx index 41a941b4..af239267 100644 --- a/src/tui/components/ColorMenu.tsx +++ b/src/tui/components/ColorMenu.tsx @@ -24,6 +24,7 @@ import { clearAllWidgetStyling, cycleWidgetColor, cycleWidgetDim, + cycleWidgetNumberStyle, resetWidgetStyling, setWidgetColor, toggleWidgetBold @@ -278,6 +279,15 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin onUpdate(newItems); } } + } else if (input === 'n' || input === 'N') { + if (highlightedItemId && highlightedItemId !== 'back') { + // Cycle number format style for the highlighted item: default -> compact -> whole -> default + const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId); + if (selectedWidget) { + const newItems = cycleWidgetNumberStyle(widgets, selectedWidget.id); + onUpdate(newItems); + } + } } else if (input === 'r' || input === 'R') { if (highlightedItemId && highlightedItemId !== 'back') { // Reset all styling (color, background, and bold) for the highlighted item @@ -444,7 +454,9 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin const styleIndicators = [ selectedWidget?.bold ? '[BOLD]' : null, selectedWidget?.dim === true ? '[DIM]' : null, - selectedWidget?.dim === 'parens' ? '[DIM ()]' : null + selectedWidget?.dim === 'parens' ? '[DIM ()]' : null, + selectedWidget?.numberFormat?.style ? `[#${selectedWidget.numberFormat.style}]` : null, + selectedWidget?.numberFormat?.decimals !== undefined ? `[#${selectedWidget.numberFormat.decimals}dp]` : null ].filter(indicator => indicator !== null).join(' '); // Gradient selection mode takes over the whole view @@ -588,7 +600,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin ↑↓ to select, ←→ to cycle {' '} {editingBackground ? 'background' : 'foreground'} - , (f) to toggle bg/fg, (b)old, (d)im, + , (f) to toggle bg/fg, (b)old, (d)im, (n)umber, {settings.colorLevel === 3 ? ' (h)ex,' : settings.colorLevel === 2 ? ' (a)nsi256,' : ''} {!editingBackground && settings.colorLevel >= 2 ? ' (g)radient,' : ''} {' '} diff --git a/src/tui/components/GlobalOverridesMenu.tsx b/src/tui/components/GlobalOverridesMenu.tsx index facfd283..defdc6ce 100644 --- a/src/tui/components/GlobalOverridesMenu.tsx +++ b/src/tui/components/GlobalOverridesMenu.tsx @@ -6,6 +6,13 @@ import { import React, { useState } from 'react'; import { getColorLevelString } from '../../types/ColorLevel'; +import { + NUMBER_KINDS, + type GlobalNumberFormat, + type NumberFormat, + type NumberKind, + type NumberStyle +} from '../../types/NumberFormat'; import { DefaultPaddingSideSchema, type Settings @@ -21,6 +28,34 @@ import { shouldInsertInput } from '../../utils/input-guards'; import { ConfirmDialog } from './ConfirmDialog'; +const NUMBER_FORMAT_STYLES: (NumberStyle | undefined)[] = [undefined, 'compact', 'whole']; + +// Cycle a number kind's global style: default (precise) -> compact -> whole -> default. +// A global style forces that kind across all widgets (see resolveNumberFormat). +function cycleGlobalNumberStyle(settings: Settings, kind: NumberKind): Settings { + const current = settings.numberFormat?.[kind]?.style; + const currentIndex = NUMBER_FORMAT_STYLES.indexOf(current); + const nextStyle = NUMBER_FORMAT_STYLES[(currentIndex + 1) % NUMBER_FORMAT_STYLES.length]; + + const kindFormat: NumberFormat = { ...settings.numberFormat?.[kind] }; + if (nextStyle === undefined) { + delete kindFormat.style; + } else { + kindFormat.style = nextStyle; + } + + const { [kind]: removedKind, ...restGlobal } = settings.numberFormat ?? {}; + void removedKind; // Intentionally unused + const nextGlobal: GlobalNumberFormat = Object.keys(kindFormat).length > 0 + ? { ...restGlobal, [kind]: kindFormat } + : restGlobal; + + return { + ...settings, + numberFormat: Object.keys(nextGlobal).length > 0 ? nextGlobal : undefined + }; +} + export interface GlobalOverridesMenuProps { settings: Settings; onUpdate: (settings: Settings) => void; @@ -36,6 +71,8 @@ export const GlobalOverridesMenu: React.FC = ({ settin const [inheritColors, setInheritColors] = useState(settings.inheritSeparatorColors); const [globalBold, setGlobalBold] = useState(settings.globalBold); const [minimalistMode, setMinimalistMode] = useState(settings.minimalistMode); + const [numberFormatMode, setNumberFormatMode] = useState(false); + const [numberFormatKindIndex, setNumberFormatKindIndex] = useState(0); const [gradientMode, setGradientMode] = useState(false); const [gradientIndex, setGradientIndex] = useState(0); const [gradientCustomStep, setGradientCustomStep] = useState<'start' | 'end' | null>(null); @@ -162,6 +199,19 @@ export const GlobalOverridesMenu: React.FC = ({ settin setGradientCustomStep('start'); } } + } else if (numberFormatMode) { + if (key.escape) { + setNumberFormatMode(false); + } else if (key.upArrow) { + setNumberFormatKindIndex((numberFormatKindIndex - 1 + NUMBER_KINDS.length) % NUMBER_KINDS.length); + } else if (key.downArrow) { + setNumberFormatKindIndex((numberFormatKindIndex + 1) % NUMBER_KINDS.length); + } else if (key.leftArrow || key.rightArrow) { + const kind = NUMBER_KINDS[numberFormatKindIndex]; + if (kind) { + onUpdate(cycleGlobalNumberStyle(settings, kind)); + } + } } else { if (key.escape) { onBack(); @@ -211,6 +261,9 @@ export const GlobalOverridesMenu: React.FC = ({ settin minimalistMode: newMinimalistMode }; onUpdate(updatedSettings); + } else if (input === 'n' || input === 'N') { + setNumberFormatMode(true); + setNumberFormatKindIndex(0); } else if (input === 'f' || input === 'F') { // Cycle through foreground colors const nextIndex = (currentFgIndex + 1) % fgColors.length; @@ -248,6 +301,34 @@ export const GlobalOverridesMenu: React.FC = ({ settin } }); + if (numberFormatMode) { + return ( + + Global Number Formatting + + ↑↓ to select a number type, ←→ to cycle its style, ESC to go back + + + {NUMBER_KINDS.map((kind, idx) => { + const style = settings.numberFormat?.[kind]?.style ?? 'precise (default)'; + return ( + + {idx === numberFormatKindIndex ? '▶ ' : ' '} + {kind} + {': '} + {style} + + ); + })} + + + precise = keep trailing zeros (1.0M), compact = trim them (1M / 1.1M), whole = no decimals (1M). + A global style forces that type across every widget. Decimal places are set per-widget or in settings.json. + + + ); + } + if (gradientMode) { const level = getColorLevelString(settings.colorLevel); @@ -372,6 +453,12 @@ export const GlobalOverridesMenu: React.FC = ({ settin - Press (m) to toggle + + Number Formatting: + {settings.numberFormat ? 'customized' : '(defaults)'} + - Press (n) to configure per-type + + Default Padding: {settings.defaultPadding ? `"${settings.defaultPadding}"` : '(none)'} diff --git a/src/tui/components/color-menu/__tests__/mutations.test.ts b/src/tui/components/color-menu/__tests__/mutations.test.ts index d4b7e300..63a39cee 100644 --- a/src/tui/components/color-menu/__tests__/mutations.test.ts +++ b/src/tui/components/color-menu/__tests__/mutations.test.ts @@ -9,6 +9,7 @@ import { clearAllWidgetStyling, cycleWidgetColor, cycleWidgetDim, + cycleWidgetNumberStyle, resetWidgetStyling, toggleWidgetBold, updateWidgetById @@ -58,7 +59,37 @@ describe('color-menu mutations', () => { expect(whole[1]?.dim).toBeUndefined(); }); - it('resetWidgetStyling removes color, backgroundColor, bold, and dim from one widget', () => { + it('cycleWidgetNumberStyle cycles default, compact, whole, then default for the selected widget only', () => { + const widgets: WidgetItem[] = [ + { id: '1', type: 'tokens-input' }, + { id: '2', type: 'tokens-output' } + ]; + + const compact = cycleWidgetNumberStyle(widgets, '1'); + const whole = cycleWidgetNumberStyle(compact, '1'); + const off = cycleWidgetNumberStyle(whole, '1'); + + expect(compact[0]?.numberFormat).toEqual({ style: 'compact' }); + expect(whole[0]?.numberFormat).toEqual({ style: 'whole' }); + expect(off[0]).toEqual({ id: '1', type: 'tokens-input' }); + expect(compact[1]?.numberFormat).toBeUndefined(); + }); + + it('cycleWidgetNumberStyle preserves an explicit decimals across the cycle', () => { + const widgets: WidgetItem[] = [ + { id: '1', type: 'tokens-input', numberFormat: { decimals: 2 } } + ]; + + const compact = cycleWidgetNumberStyle(widgets, '1'); + const whole = cycleWidgetNumberStyle(compact, '1'); + const off = cycleWidgetNumberStyle(whole, '1'); + + expect(compact[0]?.numberFormat).toEqual({ style: 'compact', decimals: 2 }); + expect(whole[0]?.numberFormat).toEqual({ style: 'whole', decimals: 2 }); + expect(off[0]?.numberFormat).toEqual({ decimals: 2 }); + }); + + it('resetWidgetStyling removes color, backgroundColor, bold, dim, and numberFormat from one widget', () => { const widgets: WidgetItem[] = [ { id: '1', @@ -66,7 +97,8 @@ describe('color-menu mutations', () => { color: 'red', backgroundColor: 'blue', bold: true, - dim: 'parens' + dim: 'parens', + numberFormat: { style: 'compact' } }, { id: '2', type: 'tokens-output', color: 'white', bold: true } ]; @@ -87,7 +119,7 @@ describe('color-menu mutations', () => { bold: true, dim: true }, - { id: '2', type: 'tokens-output', color: 'white', bold: true, dim: 'parens' } + { id: '2', type: 'tokens-output', color: 'white', bold: true, dim: 'parens', numberFormat: { style: 'whole' } } ]; const updated = clearAllWidgetStyling(widgets); diff --git a/src/tui/components/color-menu/mutations.ts b/src/tui/components/color-menu/mutations.ts index 855b7976..b5509b6c 100644 --- a/src/tui/components/color-menu/mutations.ts +++ b/src/tui/components/color-menu/mutations.ts @@ -1,3 +1,7 @@ +import type { + NumberFormat, + NumberStyle +} from '../../../types/NumberFormat'; import type { WidgetItem } from '../../../types/Widget'; import { getWidget } from '../../../utils/widgets'; @@ -60,6 +64,38 @@ export function cycleWidgetDim(widgets: WidgetItem[], widgetId: string): WidgetI }); } +export function cycleWidgetNumberStyle(widgets: WidgetItem[], widgetId: string): WidgetItem[] { + return updateWidgetById(widgets, widgetId, (widget) => { + // Cycle the number style: default (precise) -> compact -> whole -> default. + // Any explicit `decimals` is preserved across the cycle. + const currentStyle = widget.numberFormat?.style; + const nextStyle: NumberStyle | undefined = currentStyle === undefined + ? 'compact' + : currentStyle === 'compact' + ? 'whole' + : undefined; + const decimals = widget.numberFormat?.decimals; + + if (nextStyle === undefined && decimals === undefined) { + const { numberFormat, ...restWidget } = widget; + void numberFormat; // Intentionally unused + return restWidget; + } + + const nextFormat: NumberFormat = {}; + if (nextStyle !== undefined) { + nextFormat.style = nextStyle; + } + if (decimals !== undefined) { + nextFormat.decimals = decimals; + } + return { + ...widget, + numberFormat: nextFormat + }; + }); +} + export function resetWidgetStyling(widgets: WidgetItem[], widgetId: string): WidgetItem[] { return updateWidgetById(widgets, widgetId, (widget) => { const { @@ -67,12 +103,14 @@ export function resetWidgetStyling(widgets: WidgetItem[], widgetId: string): Wid backgroundColor, bold, dim, + numberFormat, ...restWidget } = widget; void color; // Intentionally unused void backgroundColor; // Intentionally unused void bold; // Intentionally unused void dim; // Intentionally unused + void numberFormat; // Intentionally unused return restWidget; }); } @@ -84,12 +122,14 @@ export function clearAllWidgetStyling(widgets: WidgetItem[]): WidgetItem[] { backgroundColor, bold, dim, + numberFormat, ...restWidget } = widget; void color; // Intentionally unused void backgroundColor; // Intentionally unused void bold; // Intentionally unused void dim; // Intentionally unused + void numberFormat; // Intentionally unused return restWidget; }); } diff --git a/src/types/NumberFormat.ts b/src/types/NumberFormat.ts new file mode 100644 index 00000000..a6cfe99d --- /dev/null +++ b/src/types/NumberFormat.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; + +// Decimal-rendering styles for numeric widgets: +// precise - fixed decimal places, trailing zeros kept ("1.0M"); today's default +// compact - trailing zeros trimmed, real fractions kept ("1M", "1.1M") +// whole - no decimals ("1M") +export const NUMBER_STYLES = ['precise', 'compact', 'whole'] as const; +export type NumberStyle = (typeof NUMBER_STYLES)[number]; + +// The kind of number a widget renders. Each kind keeps its own baseline +// precision in its formatter, so a token-oriented change never drags money off +// its 2-decimal convention. +export const NUMBER_KINDS = ['token', 'speed', 'percent', 'memory', 'cost'] as const; +export type NumberKind = (typeof NUMBER_KINDS)[number]; + +// A precision override. Both fields optional; an empty format means "use the +// formatter's built-in baseline", i.e. current output. +export const NumberFormatSchema = z.object({ + style: z.enum(NUMBER_STYLES).optional(), + decimals: z.number().int().min(0).max(6).optional() +}); +export type NumberFormat = z.infer; + +// Optional global precision, keyed by number kind. A kind set here wins over any +// per-widget value (same precedence as overrideForegroundColor / globalBold). +export const GlobalNumberFormatSchema = z.object({ + token: NumberFormatSchema.optional(), + speed: NumberFormatSchema.optional(), + percent: NumberFormatSchema.optional(), + memory: NumberFormatSchema.optional(), + cost: NumberFormatSchema.optional() +}); +export type GlobalNumberFormat = z.infer; diff --git a/src/types/Settings.ts b/src/types/Settings.ts index a596dbcf..d90696e2 100644 --- a/src/types/Settings.ts +++ b/src/types/Settings.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { ColorLevelSchema } from './ColorLevel'; import { FlexModeSchema } from './FlexMode'; +import { GlobalNumberFormatSchema } from './NumberFormat'; import { PowerlineConfigSchema } from './PowerlineConfig'; import { WidgetItemSchema } from './Widget'; @@ -73,6 +74,7 @@ export const SettingsSchema = z.object({ overrideBackgroundColor: z.string().optional(), overrideForegroundColor: z.string().optional(), globalBold: z.boolean().default(false), + numberFormat: GlobalNumberFormatSchema.optional(), gitCacheTtlSeconds: z.number().min(0).max(60).default(5), minimalistMode: z.boolean().default(false), powerline: PowerlineConfigSchema.default({ diff --git a/src/types/Widget.ts b/src/types/Widget.ts index ad2bf571..198f7379 100644 --- a/src/types/Widget.ts +++ b/src/types/Widget.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; +import { NumberFormatSchema } from './NumberFormat'; import type { RenderContext } from './RenderContext'; import type { Settings } from './Settings'; @@ -11,6 +12,7 @@ export const WidgetItemSchema = z.object({ backgroundColor: z.string().optional(), bold: z.boolean().optional(), dim: z.union([z.boolean(), z.literal('parens')]).optional(), + numberFormat: NumberFormatSchema.optional(), character: z.string().optional(), rawValue: z.boolean().optional(), customText: z.string().optional(), diff --git a/src/utils/__tests__/number-format.test.ts b/src/utils/__tests__/number-format.test.ts new file mode 100644 index 00000000..f7281f60 --- /dev/null +++ b/src/utils/__tests__/number-format.test.ts @@ -0,0 +1,128 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import type { + GlobalNumberFormat, + NumberFormat +} from '../../types/NumberFormat'; +import { DEFAULT_SETTINGS } from '../../types/Settings'; +import type { WidgetItem } from '../../types/Widget'; +import { formatTokens } from '../format-tokens'; +import { + formatCost, + formatPercent, + renderMagnitude, + resolveNumberFormat +} from '../number-format'; +import { formatSpeed } from '../speed-metrics'; + +describe('renderMagnitude', () => { + const cases: { value: number; format: NumberFormat; baseline: number; expected: string }[] = [ + { value: 1, format: {}, baseline: 1, expected: '1.0' }, + { value: 1, format: { style: 'compact' }, baseline: 1, expected: '1' }, + { value: 1.1, format: { style: 'compact' }, baseline: 1, expected: '1.1' }, + { value: 512, format: { style: 'compact' }, baseline: 1, expected: '512' }, + { value: 1, format: { decimals: 2 }, baseline: 1, expected: '1.00' }, + { value: 1.149, format: { style: 'whole' }, baseline: 1, expected: '1' }, + { value: 12, format: {}, baseline: 0, expected: '12' }, + { value: 12, format: { decimals: 1 }, baseline: 0, expected: '12.0' } + ]; + + it.each(cases)('value $value with $format over baseline $baseline -> $expected', ({ value, format, baseline, expected }) => { + expect(renderMagnitude(value, format, baseline)).toBe(expected); + }); +}); + +describe('formatPercent', () => { + it('keeps one decimal by default (unchanged)', () => { + expect(formatPercent(84.5)).toBe('84.5%'); + expect(formatPercent(100)).toBe('100.0%'); + }); + + it('compact trims a pointless trailing zero', () => { + expect(formatPercent(100, { style: 'compact' })).toBe('100%'); + expect(formatPercent(84.5, { style: 'compact' })).toBe('84.5%'); + }); + + it('whole rounds to an integer', () => { + expect(formatPercent(84.4, { style: 'whole' })).toBe('84%'); + expect(formatPercent(99.9, { style: 'whole' })).toBe('100%'); + }); +}); + +describe('formatCost', () => { + it('keeps two decimals by default (money, unchanged)', () => { + expect(formatCost(1.2)).toBe('$1.20'); + expect(formatCost(2.45)).toBe('$2.45'); + }); + + it('honors an explicit override', () => { + expect(formatCost(1.2, { style: 'compact' })).toBe('$1.2'); + expect(formatCost(1, { style: 'whole' })).toBe('$1'); + }); +}); + +describe('resolveNumberFormat', () => { + const widget = (numberFormat?: NumberFormat): WidgetItem => ({ + id: 'w', + type: 'tokens-input', + ...(numberFormat ? { numberFormat } : {}) + }); + const withGlobal = (numberFormat: GlobalNumberFormat) => ({ ...DEFAULT_SETTINGS, numberFormat }); + + it('returns an empty format when nothing is set (current behavior)', () => { + expect(resolveNumberFormat('token', widget(), DEFAULT_SETTINGS)).toEqual({}); + }); + + it('uses the per-widget value when no global is set', () => { + expect(resolveNumberFormat('token', widget({ style: 'compact' }), DEFAULT_SETTINGS)).toEqual({ style: 'compact' }); + }); + + it('lets a global for the kind win over the per-widget value', () => { + const settings = withGlobal({ token: { style: 'whole' } }); + expect(resolveNumberFormat('token', widget({ style: 'compact' }), settings)).toEqual({ style: 'whole' }); + }); + + it('ignores a global set for a different kind', () => { + const settings = withGlobal({ cost: { style: 'whole' } }); + expect(resolveNumberFormat('token', widget({ style: 'compact' }), settings)).toEqual({ style: 'compact' }); + }); +}); + +describe('formatTokens with a format', () => { + it('compact trims pointless trailing zeros', () => { + expect(formatTokens(1000000, { style: 'compact' })).toBe('1M'); + expect(formatTokens(1147000, { style: 'compact' })).toBe('1.1M'); + expect(formatTokens(512000, { style: 'compact' })).toBe('512k'); + }); + + it('whole drops decimals and still promotes to M correctly', () => { + expect(formatTokens(1000000, { style: 'whole' })).toBe('1M'); + expect(formatTokens(999600, { style: 'whole' })).toBe('1M'); + }); + + it('decimals widens precision', () => { + expect(formatTokens(1000000, { decimals: 2 })).toBe('1.00M'); + }); + + it('default (no format) is unchanged', () => { + expect(formatTokens(1000000)).toBe('1.0M'); + expect(formatTokens(512000)).toBe('512.0k'); + }); +}); + +describe('formatSpeed with a format', () => { + it('compact trims trailing zeros', () => { + expect(formatSpeed(1000, { style: 'compact' })).toBe('1k t/s'); + expect(formatSpeed(50, { style: 'compact' })).toBe('50 t/s'); + }); + + it('default is unchanged', () => { + expect(formatSpeed(1000)).toBe('1.0k t/s'); + expect(formatSpeed(50)).toBe('50.0 t/s'); + expect(formatSpeed(null)).toBe('—'); + }); +}); diff --git a/src/utils/__tests__/renderer-format-tokens.test.ts b/src/utils/__tests__/renderer-format-tokens.test.ts index a929e5fa..6f073feb 100644 --- a/src/utils/__tests__/renderer-format-tokens.test.ts +++ b/src/utils/__tests__/renderer-format-tokens.test.ts @@ -35,9 +35,9 @@ describe('formatTokens', () => { }); it('uses whole-number k and rolls up to M at decimals=0', () => { - expect(formatTokens(711000, 0)).toBe('711k'); - expect(formatTokens(999499, 0)).toBe('999k'); - expect(formatTokens(999500, 0)).toBe('1.0M'); - expect(formatTokens(1000000, 0)).toBe('1.0M'); + expect(formatTokens(711000, {}, 0)).toBe('711k'); + expect(formatTokens(999499, {}, 0)).toBe('999k'); + expect(formatTokens(999500, {}, 0)).toBe('1.0M'); + expect(formatTokens(1000000, {}, 0)).toBe('1.0M'); }); }); diff --git a/src/utils/format-tokens.ts b/src/utils/format-tokens.ts index d68ec0a4..66558ba0 100644 --- a/src/utils/format-tokens.ts +++ b/src/utils/format-tokens.ts @@ -1,12 +1,21 @@ -// Format a token count with `decimals` places in the "k" range. Once the k -// value would round up to "1000" at that precision (within half a displayed -// unit of 1M), promote to "1.0M" instead — at decimals=1 that boundary is -// 999_950 ("1000.0k" -> "1.0M"), at decimals=0 it is 999_500 ("1000k" -> "1.0M"). -// decimals defaults to 1; callers wanting a compact whole-number k pass 0. -export function formatTokens(count: number, decimals = 1): string { - if (count >= 1000000 - 500 / 10 ** decimals) - return `${(count / 1000000).toFixed(1)}M`; +import type { NumberFormat } from '../types/NumberFormat'; + +import { + effectiveDecimals, + renderMagnitude +} from './number-format'; + +// Format a token count, applying the optional `format` (style/decimals) on top +// of the "k"-range `decimals` baseline. Once the k value would round up to +// "1000" at the effective precision (within half a displayed unit of 1M), +// promote to the "M" range — at 1 decimal that boundary is 999_950, at 0 it is +// 999_500. `decimals` defaults to 1; callers wanting compact whole-number k pass +// 0. With no `format`, output is unchanged ("1.0M", "512.0k"). +export function formatTokens(count: number, format: NumberFormat = {}, decimals = 1): string { + const kDecimals = effectiveDecimals(format, decimals); + if (count >= 1000000 - 500 / 10 ** kDecimals) + return `${renderMagnitude(count / 1000000, format, 1)}M`; if (count >= 1000) - return `${(count / 1000).toFixed(decimals)}k`; + return `${renderMagnitude(count / 1000, format, decimals)}k`; return count.toString(); } diff --git a/src/utils/number-format.ts b/src/utils/number-format.ts new file mode 100644 index 00000000..4aa0967c --- /dev/null +++ b/src/utils/number-format.ts @@ -0,0 +1,47 @@ +import type { + NumberFormat, + NumberKind +} from '../types/NumberFormat'; +import type { Settings } from '../types/Settings'; +import type { WidgetItem } from '../types/Widget'; + +// Strip a pointless trailing ".0" (or ".00", ...) so a whole value reads cleanly +// while a meaningful fraction is left intact. +// "512.0" -> "512" "1.0" -> "1" "5.2" -> "5.2" "711" -> "711" +function trimTrailingZeros(value: string): string { + return value.includes('.') ? value.replace(/\.?0+$/, '') : value; +} + +// Decimal count a format resolves to over a tier's baseline precision. +export function effectiveDecimals(format: NumberFormat, baselineDecimals: number): number { + if (format.style === 'whole') + return 0; + return format.decimals ?? baselineDecimals; +} + +// Render a magnitude value (already divided into its k/M/G unit, or a bare +// percentage/cost) to its numeric string under `format`. `baselineDecimals` is +// the caller's own default precision, used when the format leaves it unset. An +// empty format reproduces the caller's current output. +export function renderMagnitude(value: number, format: NumberFormat, baselineDecimals: number): string { + const fixed = value.toFixed(effectiveDecimals(format, baselineDecimals)); + return format.style === 'compact' ? trimTrailingZeros(fixed) : fixed; +} + +// Render a percentage. `baselineDecimals` defaults to 1 ("84.5%"); callers that +// want a whole-percent baseline (e.g. a context bar) pass 0. +export function formatPercent(value: number, format: NumberFormat = {}, baselineDecimals = 1): string { + return `${renderMagnitude(value, format, baselineDecimals)}%`; +} + +// Render a USD cost (baseline 2 decimals: "$1.20"). +export function formatCost(value: number, format: NumberFormat = {}): string { + return `$${renderMagnitude(value, format, 2)}`; +} + +// Resolve the effective format for a widget of the given kind. A global entry for +// the kind wins outright; otherwise the widget's own value; otherwise an empty +// format (formatter baseline = current output). +export function resolveNumberFormat(kind: NumberKind, item: WidgetItem, settings: Settings): NumberFormat { + return settings.numberFormat?.[kind] ?? item.numberFormat ?? {}; +} diff --git a/src/utils/speed-metrics.ts b/src/utils/speed-metrics.ts index d08818ca..b22707de 100644 --- a/src/utils/speed-metrics.ts +++ b/src/utils/speed-metrics.ts @@ -1,5 +1,8 @@ +import type { NumberFormat } from '../types/NumberFormat'; import type { SpeedMetrics } from '../types/SpeedMetrics'; +import { renderMagnitude } from './number-format'; + /** * Calculates output tokens per second from speed metrics. * @@ -46,17 +49,17 @@ export function calculateTotalSpeed(metrics: SpeedMetrics): number | null { * Formats a tokens per second value for display. * * @param tokensPerSec Tokens per second value, or null if unavailable + * @param format Optional precision override (style/decimals); empty = unchanged * @returns Formatted string (e.g., "42.5 t/s", "1.2k t/s", or "—" for null) */ -export function formatSpeed(tokensPerSec: number | null): string { +export function formatSpeed(tokensPerSec: number | null, format: NumberFormat = {}): string { if (tokensPerSec === null) { return '—'; } if (tokensPerSec >= 1000) { - const kValue = tokensPerSec / 1000; - return `${kValue.toFixed(1)}k t/s`; + return `${renderMagnitude(tokensPerSec / 1000, format, 1)}k t/s`; } - return `${tokensPerSec.toFixed(1)} t/s`; + return `${renderMagnitude(tokensPerSec, format, 1)} t/s`; } diff --git a/src/widgets/BlockResetTimer.ts b/src/widgets/BlockResetTimer.ts index 192d36dd..807d9183 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); } diff --git a/src/widgets/BlockTimer.ts b/src/widgets/BlockTimer.ts index 9d39eb07..1fa53357 100644 --- a/src/widgets/BlockTimer.ts +++ b/src/widgets/BlockTimer.ts @@ -6,6 +6,10 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { + formatPercent, + resolveNumberFormat +} from '../utils/number-format'; import { formatUsageDuration, resolveUsageWindowWithFallback @@ -61,6 +65,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; @@ -68,13 +73,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); } @@ -107,15 +112,14 @@ export class BlockTimerWidget implements Widget { const barWidth = getUsageProgressBarWidth(displayMode); const percent = inverted ? window.remainingPercent : window.elapsedPercent; const progressBar = makeTimerProgressBar(percent, barWidth); - const percentage = percent.toFixed(1); - return formatRawOrLabeledValue(item, 'Block ', `[${progressBar}] ${percentage}%`); + return formatRawOrLabeledValue(item, 'Block ', `[${progressBar}] ${formatPercent(percent, format)}`); } if (isUsageSliderMode(displayMode)) { const percent = inverted ? window.remainingPercent : window.elapsedPercent; const slider = makeSliderBar(percent); const sliderDisplay = displayMode === 'slider' - ? `${slider} ${percent.toFixed(1)}%` + ? `${slider} ${formatPercent(percent, format)}` : slider; return formatRawOrLabeledValue(item, 'Block ', sliderDisplay); } diff --git a/src/widgets/CacheHitRate.ts b/src/widgets/CacheHitRate.ts index 4544bb87..900c5a3f 100644 --- a/src/widgets/CacheHitRate.ts +++ b/src/widgets/CacheHitRate.ts @@ -6,6 +6,10 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { + formatPercent, + resolveNumberFormat +} from '../utils/number-format'; import { getCacheHitRate, @@ -53,7 +57,8 @@ export class CacheHitRateWidget implements Widget { return null; } - return formatRawOrLabeledValue(item, 'Cache Hit: ', `${hitRate.toFixed(1)}%`); + const format = resolveNumberFormat('percent', item, settings); + return formatRawOrLabeledValue(item, 'Cache Hit: ', formatPercent(hitRate, format)); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { diff --git a/src/widgets/CacheRead.ts b/src/widgets/CacheRead.ts index b14b1afb..54b98166 100644 --- a/src/widgets/CacheRead.ts +++ b/src/widgets/CacheRead.ts @@ -6,6 +6,7 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { resolveNumberFormat } from '../utils/number-format'; import { formatTokensWithPercentage, @@ -49,7 +50,9 @@ export class CacheReadWidget implements Widget { return null; } - const value = formatTokensWithPercentage(tokens.read, getCacheReadPercentage(tokens)); + const tokenFormat = resolveNumberFormat('token', item, settings); + const percentFormat = resolveNumberFormat('percent', item, settings); + const value = formatTokensWithPercentage(tokens.read, getCacheReadPercentage(tokens), tokenFormat, percentFormat); return formatRawOrLabeledValue(item, 'Cache Read: ', value); } diff --git a/src/widgets/CacheWrite.ts b/src/widgets/CacheWrite.ts index 63807bbd..d9f8b663 100644 --- a/src/widgets/CacheWrite.ts +++ b/src/widgets/CacheWrite.ts @@ -6,6 +6,7 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { resolveNumberFormat } from '../utils/number-format'; import { formatTokensWithPercentage, @@ -49,7 +50,9 @@ export class CacheWriteWidget implements Widget { return null; } - const value = formatTokensWithPercentage(tokens.creation, getCacheWritePercentage(tokens)); + const tokenFormat = resolveNumberFormat('token', item, settings); + const percentFormat = resolveNumberFormat('percent', item, settings); + const value = formatTokensWithPercentage(tokens.creation, getCacheWritePercentage(tokens), tokenFormat, percentFormat); return formatRawOrLabeledValue(item, 'Cache Write: ', value); } diff --git a/src/widgets/CompactionCounter.ts b/src/widgets/CompactionCounter.ts index 7b2a0212..e95e3829 100644 --- a/src/widgets/CompactionCounter.ts +++ b/src/widgets/CompactionCounter.ts @@ -1,3 +1,4 @@ +import type { NumberFormat } from '../types/NumberFormat'; import type { CompactionData, RenderContext @@ -12,6 +13,7 @@ import type { } from '../types/Widget'; import { ZERO_COMPACTION_STATS } from '../utils/compaction'; import { formatTokens } from '../utils/format-tokens'; +import { resolveNumberFormat } from '../utils/number-format'; import { isMetadataFlagEnabled, @@ -122,12 +124,12 @@ function getMetricValue(data: CompactionData, metric: CompactionMetric): number } } -function formatReclaimedSuffix(tokensReclaimed: number, item: WidgetItem): string { +function formatReclaimedSuffix(tokensReclaimed: number, item: WidgetItem, format: NumberFormat): string { if (tokensReclaimed <= 0) { return ''; } const symbol = getSlotSymbol(item, RECLAIMED_SLOT); - return symbol.length > 0 ? ` ${symbol}${formatTokens(tokensReclaimed)}` : ` ${formatTokens(tokensReclaimed)}`; + return symbol.length > 0 ? ` ${symbol}${formatTokens(tokensReclaimed, format)}` : ` ${formatTokens(tokensReclaimed, format)}`; } function formatTriggerSuffix(byTrigger: CompactionData['byTrigger']): string { @@ -144,13 +146,13 @@ function formatTriggerSuffix(byTrigger: CompactionData['byTrigger']): string { return parts.length > 0 ? ` (${parts.join(', ')})` : ''; } -function formatStats(data: CompactionData, item: WidgetItem, icon: string): string { +function formatStats(data: CompactionData, item: WidgetItem, icon: string, format: NumberFormat): string { let out = formatCount(data.count, getFormat(item), icon); if (isMetadataFlagEnabled(item, SHOW_TRIGGERS_METADATA_KEY)) { out += formatTriggerSuffix(data.byTrigger); } if (isMetadataFlagEnabled(item, SHOW_RECLAIMED_METADATA_KEY)) { - out += formatReclaimedSuffix(data.tokensReclaimed, item); + out += formatReclaimedSuffix(data.tokensReclaimed, item, format); } return out; } @@ -243,7 +245,7 @@ export class CompactionCounterWidget implements Widget { } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { - void settings; + const format = resolveNumberFormat('token', item, settings); const data = context.isPreview ? SAMPLE_STATS : (context.compactionData ?? ZERO_COMPACTION_STATS); const metric = getMetric(item); @@ -252,7 +254,7 @@ export class CompactionCounterWidget implements Widget { if (value === 0 && isHideZeroEnabled(item) && !context.isPreview) { return null; } - return metric === 'reclaimed' ? formatTokens(value) : String(value); + return metric === 'reclaimed' ? formatTokens(value, format) : String(value); } if (data.count === 0 && isHideZeroEnabled(item) && !context.isPreview) { @@ -260,7 +262,7 @@ export class CompactionCounterWidget implements Widget { } const icon = isNerdFontEnabled(item, NERD_FONT_FORMATS) ? COMPACTION_NERD_FONT_ICON : COMPACTION_ICON; - return formatStats(data, item, icon); + return formatStats(data, item, icon, format); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { diff --git a/src/widgets/ContextBar.ts b/src/widgets/ContextBar.ts index 400bc54d..89a2454a 100644 --- a/src/widgets/ContextBar.ts +++ b/src/widgets/ContextBar.ts @@ -11,6 +11,10 @@ import { getContextConfig, getModelContextIdentifier } from '../utils/model-context'; +import { + formatPercent, + resolveNumberFormat +} from '../utils/number-format'; import { formatTokens } from '../utils/renderer'; import { makeUsageProgressBar } from '../utils/usage'; @@ -79,6 +83,8 @@ export class ContextBarWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { const displayMode = getDisplayMode(item); + const tokenFormat = resolveNumberFormat('token', item, settings); + const percentFormat = resolveNumberFormat('percent', item, settings); if (context.isPreview) { if (isBarSliderMode(displayMode)) { @@ -111,17 +117,18 @@ export class ContextBarWidget implements Widget { const percent = (used / total) * 100; const clampedPercent = Math.max(0, Math.min(100, percent)); - const usedDisplay = formatTokens(used, 0); - const totalDisplay = formatTokens(total, 0); + const usedDisplay = formatTokens(used, tokenFormat, 0); + const totalDisplay = formatTokens(total, tokenFormat, 0); + const percentDisplay = formatPercent(clampedPercent, percentFormat, 0); if (isBarSliderMode(displayMode)) { const slider = makeSliderBar(clampedPercent); - const sliderDisplay = displayMode === 'slider' ? `${slider} ${usedDisplay}/${totalDisplay} (${Math.round(clampedPercent)}%)` : slider; + const sliderDisplay = displayMode === 'slider' ? `${slider} ${usedDisplay}/${totalDisplay} (${percentDisplay})` : slider; return item.rawValue ? sliderDisplay : `Context: ${sliderDisplay}`; } const barWidth = displayMode === 'progress' ? 32 : 16; - const display = `${makeUsageProgressBar(clampedPercent, barWidth)} ${usedDisplay}/${totalDisplay} (${Math.round(clampedPercent)}%)`; + const display = `${makeUsageProgressBar(clampedPercent, barWidth)} ${usedDisplay}/${totalDisplay} (${percentDisplay})`; return item.rawValue ? display : `Context: ${display}`; } diff --git a/src/widgets/ContextLength.ts b/src/widgets/ContextLength.ts index 85c9c312..92575f71 100644 --- a/src/widgets/ContextLength.ts +++ b/src/widgets/ContextLength.ts @@ -6,6 +6,7 @@ import type { WidgetItem } from '../types/Widget'; import { getContextWindowContextLengthTokens } from '../utils/context-window'; +import { resolveNumberFormat } from '../utils/number-format'; import { formatTokens } from '../utils/renderer'; export class ContextLengthWidget implements Widget { @@ -18,17 +19,18 @@ export class ContextLengthWidget implements Widget { } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + const format = resolveNumberFormat('token', item, settings); if (context.isPreview) { return item.rawValue ? '18.6k' : 'Ctx: 18.6k'; } const contextLengthTokens = getContextWindowContextLengthTokens(context.data); if (contextLengthTokens !== null) { - return item.rawValue ? formatTokens(contextLengthTokens) : `Ctx: ${formatTokens(contextLengthTokens)}`; + return item.rawValue ? formatTokens(contextLengthTokens, format) : `Ctx: ${formatTokens(contextLengthTokens, format)}`; } if (context.tokenMetrics) { - return item.rawValue ? formatTokens(context.tokenMetrics.contextLength) : `Ctx: ${formatTokens(context.tokenMetrics.contextLength)}`; + return item.rawValue ? formatTokens(context.tokenMetrics.contextLength, format) : `Ctx: ${formatTokens(context.tokenMetrics.contextLength, format)}`; } return null; } diff --git a/src/widgets/ContextPercentage.ts b/src/widgets/ContextPercentage.ts index f6940106..69aff8b3 100644 --- a/src/widgets/ContextPercentage.ts +++ b/src/widgets/ContextPercentage.ts @@ -7,6 +7,10 @@ import type { WidgetItem } from '../types/Widget'; import { calculateContextPercentageMetrics } from '../utils/context-percentage'; +import { + formatPercent, + resolveNumberFormat +} from '../utils/number-format'; import { getContextInverseModifierText, @@ -50,10 +54,11 @@ export class ContextPercentageWidget implements Widget { const label = isInverse ? 'Ctx Left: ' : 'Ctx Used: '; const sliderMode = getContextSliderMode(item); const contextPercentageMetrics = calculateContextPercentageMetrics(context); + const format = resolveNumberFormat('percent', item, settings); const formatContextPercentage = (displayPercentage: number): string => { - const sliderResult = renderContextSlider(sliderMode, displayPercentage); - return formatRawOrLabeledValue(item, label, sliderResult ?? `${displayPercentage.toFixed(1)}%`); + const sliderResult = renderContextSlider(sliderMode, displayPercentage, format); + return formatRawOrLabeledValue(item, label, sliderResult ?? formatPercent(displayPercentage, format)); }; if (context.isPreview) { diff --git a/src/widgets/ContextPercentageUsable.ts b/src/widgets/ContextPercentageUsable.ts index 9dd8c679..e5dc14e0 100644 --- a/src/widgets/ContextPercentageUsable.ts +++ b/src/widgets/ContextPercentageUsable.ts @@ -11,6 +11,10 @@ import { getContextConfig, getModelContextIdentifier } from '../utils/model-context'; +import { + formatPercent, + resolveNumberFormat +} from '../utils/number-format'; import { getContextInverseModifierText, @@ -56,10 +60,11 @@ export class ContextPercentageUsableWidget implements Widget { const modelIdentifier = getModelContextIdentifier(context.data?.model); const contextWindowMetrics = getContextWindowMetrics(context.data); const contextConfig = getContextConfig(modelIdentifier, contextWindowMetrics.windowSize); + const format = resolveNumberFormat('percent', item, settings); const formatContextPercentage = (displayPercentage: number): string => { - const sliderResult = renderContextSlider(sliderMode, displayPercentage); - return formatRawOrLabeledValue(item, label, sliderResult ?? `${displayPercentage.toFixed(1)}%`); + const sliderResult = renderContextSlider(sliderMode, displayPercentage, format); + return formatRawOrLabeledValue(item, label, sliderResult ?? formatPercent(displayPercentage, format)); }; if (context.isPreview) { diff --git a/src/widgets/ContextWindow.ts b/src/widgets/ContextWindow.ts index 3c2b102f..51d12e9f 100644 --- a/src/widgets/ContextWindow.ts +++ b/src/widgets/ContextWindow.ts @@ -10,6 +10,7 @@ import { getContextConfig, getModelContextIdentifier } from '../utils/model-context'; +import { resolveNumberFormat } from '../utils/number-format'; import { formatTokens } from '../utils/renderer'; export class ContextWindowWidget implements Widget { @@ -22,6 +23,7 @@ export class ContextWindowWidget implements Widget { } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + const format = resolveNumberFormat('token', item, settings); if (context.isPreview) { return item.rawValue ? '200k' : 'Win: 200k'; } @@ -37,7 +39,7 @@ export class ContextWindowWidget implements Widget { return null; } - return item.rawValue ? formatTokens(total) : `Win: ${formatTokens(total)}`; + return item.rawValue ? formatTokens(total, format) : `Win: ${formatTokens(total, format)}`; } supportsRawValue(): boolean { return true; } diff --git a/src/widgets/ExtraUsageUtilization.ts b/src/widgets/ExtraUsageUtilization.ts index 63d8348c..cbb8bbb1 100644 --- a/src/widgets/ExtraUsageUtilization.ts +++ b/src/widgets/ExtraUsageUtilization.ts @@ -6,6 +6,10 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { + formatPercent, + resolveNumberFormat +} from '../utils/number-format'; import { getUsageErrorMessage } from '../utils/usage'; import { @@ -65,6 +69,7 @@ export class ExtraUsageUtilizationWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { const displayMode = getUsageDisplayMode(item); const inverted = isUsageInverted(item); + const format = resolveNumberFormat('percent', item, settings); if (context.isPreview) { const previewPercent = 2.6; @@ -73,16 +78,16 @@ export class ExtraUsageUtilizationWidget implements Widget { if (isUsageProgressMode(displayMode)) { const width = getUsageProgressBarWidth(displayMode); const progressBar = makeTimerProgressBar(renderedPercent, width); - return formatRawOrLabeledValue(item, 'Overage: ', `[${progressBar}] ${renderedPercent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, 'Overage: ', `[${progressBar}] ${formatPercent(renderedPercent, format)}`); } if (isUsageSliderMode(displayMode)) { const slider = makeSliderBar(renderedPercent); - const sliderDisplay = displayMode === 'slider' ? `${slider} ${renderedPercent.toFixed(1)}%` : slider; + const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(renderedPercent, format)}` : slider; return formatRawOrLabeledValue(item, 'Overage: ', sliderDisplay); } - return formatRawOrLabeledValue(item, 'Overage: ', `${renderedPercent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, 'Overage: ', formatPercent(renderedPercent, format)); } const data = context.usageData ?? {}; @@ -104,16 +109,16 @@ export class ExtraUsageUtilizationWidget implements Widget { if (isUsageProgressMode(displayMode)) { const width = getUsageProgressBarWidth(displayMode); const progressBar = makeTimerProgressBar(renderedPercent, width); - return formatRawOrLabeledValue(item, 'Overage: ', `[${progressBar}] ${renderedPercent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, 'Overage: ', `[${progressBar}] ${formatPercent(renderedPercent, format)}`); } if (isUsageSliderMode(displayMode)) { const slider = makeSliderBar(renderedPercent); - const sliderDisplay = displayMode === 'slider' ? `${slider} ${renderedPercent.toFixed(1)}%` : slider; + const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(renderedPercent, format)}` : slider; return formatRawOrLabeledValue(item, 'Overage: ', sliderDisplay); } - return formatRawOrLabeledValue(item, 'Overage: ', `${renderedPercent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, 'Overage: ', formatPercent(renderedPercent, format)); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { diff --git a/src/widgets/FreeMemory.ts b/src/widgets/FreeMemory.ts index 4e82d7c2..2d865f80 100644 --- a/src/widgets/FreeMemory.ts +++ b/src/widgets/FreeMemory.ts @@ -1,6 +1,7 @@ import { execSync } from 'child_process'; import os from 'os'; +import type { NumberFormat } from '../types/NumberFormat'; import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { @@ -8,18 +9,22 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { + renderMagnitude, + resolveNumberFormat +} from '../utils/number-format'; -function formatBytes(bytes: number): string { +function formatBytes(bytes: number, format: NumberFormat): string { const GB = 1024 ** 3; const MB = 1024 ** 2; const KB = 1024; if (bytes >= GB) - return `${(bytes / GB).toFixed(1)}G`; + return `${renderMagnitude(bytes / GB, format, 1)}G`; if (bytes >= MB) - return `${(bytes / MB).toFixed(0)}M`; + return `${renderMagnitude(bytes / MB, format, 0)}M`; if (bytes >= KB) - return `${(bytes / KB).toFixed(0)}K`; + return `${renderMagnitude(bytes / KB, format, 0)}K`; return `${bytes}B`; } @@ -71,6 +76,7 @@ export class FreeMemoryWidget implements Widget { } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + const format = resolveNumberFormat('memory', item, settings); if (context.isPreview) { return item.rawValue ? '12.4G/16.0G' : 'Mem: 12.4G/16.0G'; } @@ -86,7 +92,7 @@ export class FreeMemoryWidget implements Widget { used = total - os.freemem(); } - const value = `${formatBytes(used)}/${formatBytes(total)}`; + const value = `${formatBytes(used, format)}/${formatBytes(total, format)}`; return item.rawValue ? value : `Mem: ${value}`; } diff --git a/src/widgets/InputSpeed.ts b/src/widgets/InputSpeed.ts index f0f37f6a..70321586 100644 --- a/src/widgets/InputSpeed.ts +++ b/src/widgets/InputSpeed.ts @@ -27,8 +27,7 @@ export class InputSpeedWidget implements Widget { } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { - void settings; - return renderSpeedWidgetValue('input', item, context); + return renderSpeedWidgetValue('input', item, context, settings); } getCustomKeybinds(): CustomKeybind[] { diff --git a/src/widgets/OutputSpeed.ts b/src/widgets/OutputSpeed.ts index 66aa8ef0..43a30a9c 100644 --- a/src/widgets/OutputSpeed.ts +++ b/src/widgets/OutputSpeed.ts @@ -27,8 +27,7 @@ export class OutputSpeedWidget implements Widget { } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { - void settings; - return renderSpeedWidgetValue('output', item, context); + return renderSpeedWidgetValue('output', item, context, settings); } getCustomKeybinds(): CustomKeybind[] { diff --git a/src/widgets/SessionCost.ts b/src/widgets/SessionCost.ts index e3dcdfed..684c8054 100644 --- a/src/widgets/SessionCost.ts +++ b/src/widgets/SessionCost.ts @@ -5,6 +5,10 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { + formatCost, + resolveNumberFormat +} from '../utils/number-format'; export class SessionCostWidget implements Widget { getDefaultColor(): string { return 'green'; } @@ -16,6 +20,7 @@ export class SessionCostWidget implements Widget { } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + const format = resolveNumberFormat('cost', item, settings); if (context.isPreview) { return item.rawValue ? '$2.45' : 'Cost: $2.45'; } @@ -25,8 +30,7 @@ export class SessionCostWidget implements Widget { return null; } - // Format the cost to 2 decimal places - const formattedCost = `$${totalCost.toFixed(2)}`; + const formattedCost = formatCost(totalCost, format); return item.rawValue ? formattedCost : `Cost: ${formattedCost}`; } diff --git a/src/widgets/SessionUsage.ts b/src/widgets/SessionUsage.ts index 2c9710db..06418ae5 100644 --- a/src/widgets/SessionUsage.ts +++ b/src/widgets/SessionUsage.ts @@ -6,6 +6,10 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { + formatPercent, + resolveNumberFormat +} from '../utils/number-format'; import { getUsageErrorMessage, resolveUsageWindowWithFallback @@ -61,6 +65,7 @@ export class SessionUsageWidget implements Widget { const displayMode = getUsageDisplayMode(item); const inverted = isUsageInverted(item); const showCursor = isUsageCursorEnabled(item); + const format = resolveNumberFormat('percent', item, settings); if (context.isPreview) { const previewPercent = 20; @@ -69,17 +74,17 @@ export class SessionUsageWidget implements Widget { if (isUsageProgressMode(displayMode)) { const width = getUsageProgressBarWidth(displayMode); const progressBar = makeTimerProgressBar(renderedPercent, width, showCursor ? { cursorPercent: 50 } : undefined); - const progressDisplay = `[${progressBar}] ${renderedPercent.toFixed(1)}%`; + const progressDisplay = `[${progressBar}] ${formatPercent(renderedPercent, format)}`; return formatRawOrLabeledValue(item, 'Session: ', progressDisplay); } if (isUsageSliderMode(displayMode)) { const slider = makeSliderBar(renderedPercent, undefined, showCursor ? { cursorPercent: 50 } : undefined); - const sliderDisplay = displayMode === 'slider' ? `${slider} ${renderedPercent.toFixed(1)}%` : slider; + const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(renderedPercent, format)}` : slider; return formatRawOrLabeledValue(item, 'Session: ', sliderDisplay); } - return formatRawOrLabeledValue(item, 'Session: ', `${renderedPercent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, 'Session: ', formatPercent(renderedPercent, format)); } const data = context.usageData ?? {}; @@ -104,17 +109,17 @@ export class SessionUsageWidget implements Widget { const width = getUsageProgressBarWidth(displayMode); const progressBar = makeTimerProgressBar(renderedPercent, width, getCursorOptions()); - const progressDisplay = `[${progressBar}] ${renderedPercent.toFixed(1)}%`; + const progressDisplay = `[${progressBar}] ${formatPercent(renderedPercent, format)}`; return formatRawOrLabeledValue(item, 'Session: ', progressDisplay); } if (isUsageSliderMode(displayMode)) { const slider = makeSliderBar(renderedPercent, undefined, getCursorOptions()); - const sliderDisplay = displayMode === 'slider' ? `${slider} ${renderedPercent.toFixed(1)}%` : slider; + const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(renderedPercent, format)}` : slider; return formatRawOrLabeledValue(item, 'Session: ', sliderDisplay); } - return formatRawOrLabeledValue(item, 'Session: ', `${renderedPercent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, 'Session: ', formatPercent(renderedPercent, format)); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { diff --git a/src/widgets/TokensCached.ts b/src/widgets/TokensCached.ts index c1fd8bfb..37d62664 100644 --- a/src/widgets/TokensCached.ts +++ b/src/widgets/TokensCached.ts @@ -5,6 +5,7 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { resolveNumberFormat } from '../utils/number-format'; import { formatTokens } from '../utils/renderer'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; @@ -19,12 +20,13 @@ export class TokensCachedWidget implements Widget { } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + const format = resolveNumberFormat('token', item, settings); if (context.isPreview) { return formatRawOrLabeledValue(item, 'Cached: ', '12k'); } if (context.tokenMetrics) { - return formatRawOrLabeledValue(item, 'Cached: ', formatTokens(context.tokenMetrics.cachedTokens)); + return formatRawOrLabeledValue(item, 'Cached: ', formatTokens(context.tokenMetrics.cachedTokens, format)); } return null; } diff --git a/src/widgets/TokensInput.ts b/src/widgets/TokensInput.ts index dfebe6b4..6e672f09 100644 --- a/src/widgets/TokensInput.ts +++ b/src/widgets/TokensInput.ts @@ -6,6 +6,7 @@ import type { WidgetItem } from '../types/Widget'; import { getContextWindowInputTotalTokens } from '../utils/context-window'; +import { resolveNumberFormat } from '../utils/number-format'; import { formatTokens } from '../utils/renderer'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; @@ -20,17 +21,18 @@ export class TokensInputWidget implements Widget { } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + const format = resolveNumberFormat('token', item, settings); if (context.isPreview) { return formatRawOrLabeledValue(item, 'In: ', '15.2k'); } if (context.tokenMetrics) { - return formatRawOrLabeledValue(item, 'In: ', formatTokens(context.tokenMetrics.inputTokens)); + return formatRawOrLabeledValue(item, 'In: ', formatTokens(context.tokenMetrics.inputTokens, format)); } const inputTotalTokens = getContextWindowInputTotalTokens(context.data); if (inputTotalTokens !== null) { - return formatRawOrLabeledValue(item, 'In: ', formatTokens(inputTotalTokens)); + return formatRawOrLabeledValue(item, 'In: ', formatTokens(inputTotalTokens, format)); } return null; } diff --git a/src/widgets/TokensOutput.ts b/src/widgets/TokensOutput.ts index a9e297a9..92c0b30e 100644 --- a/src/widgets/TokensOutput.ts +++ b/src/widgets/TokensOutput.ts @@ -6,6 +6,7 @@ import type { WidgetItem } from '../types/Widget'; import { getContextWindowOutputTotalTokens } from '../utils/context-window'; +import { resolveNumberFormat } from '../utils/number-format'; import { formatTokens } from '../utils/renderer'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; @@ -20,17 +21,18 @@ export class TokensOutputWidget implements Widget { } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + const format = resolveNumberFormat('token', item, settings); if (context.isPreview) { return formatRawOrLabeledValue(item, 'Out: ', '3.4k'); } if (context.tokenMetrics) { - return formatRawOrLabeledValue(item, 'Out: ', formatTokens(context.tokenMetrics.outputTokens)); + return formatRawOrLabeledValue(item, 'Out: ', formatTokens(context.tokenMetrics.outputTokens, format)); } const outputTotalTokens = getContextWindowOutputTotalTokens(context.data); if (outputTotalTokens !== null) { - return formatRawOrLabeledValue(item, 'Out: ', formatTokens(outputTotalTokens)); + return formatRawOrLabeledValue(item, 'Out: ', formatTokens(outputTotalTokens, format)); } return null; } diff --git a/src/widgets/TokensTotal.ts b/src/widgets/TokensTotal.ts index 5f6e57bd..15aa9bb0 100644 --- a/src/widgets/TokensTotal.ts +++ b/src/widgets/TokensTotal.ts @@ -5,6 +5,7 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { resolveNumberFormat } from '../utils/number-format'; import { formatTokens } from '../utils/renderer'; import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; @@ -19,12 +20,13 @@ export class TokensTotalWidget implements Widget { } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + const format = resolveNumberFormat('token', item, settings); if (context.isPreview) { return formatRawOrLabeledValue(item, 'Total: ', '30.6k'); } if (context.tokenMetrics) { - return formatRawOrLabeledValue(item, 'Total: ', formatTokens(context.tokenMetrics.totalTokens)); + return formatRawOrLabeledValue(item, 'Total: ', formatTokens(context.tokenMetrics.totalTokens, format)); } return null; } diff --git a/src/widgets/TotalSpeed.ts b/src/widgets/TotalSpeed.ts index 000d9dc8..9fa950c5 100644 --- a/src/widgets/TotalSpeed.ts +++ b/src/widgets/TotalSpeed.ts @@ -27,8 +27,7 @@ export class TotalSpeedWidget implements Widget { } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { - void settings; - return renderSpeedWidgetValue('total', item, context); + return renderSpeedWidgetValue('total', item, context, settings); } getCustomKeybinds(): CustomKeybind[] { diff --git a/src/widgets/WeeklyOpusUsage.ts b/src/widgets/WeeklyOpusUsage.ts index 8d17d4be..b1f96035 100644 --- a/src/widgets/WeeklyOpusUsage.ts +++ b/src/widgets/WeeklyOpusUsage.ts @@ -6,6 +6,10 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { + formatPercent, + resolveNumberFormat +} from '../utils/number-format'; import { getUsageErrorMessage, resolveWeeklyOpusUsageWindow @@ -63,6 +67,7 @@ export class WeeklyOpusUsageWidget implements Widget { const displayMode = getUsageDisplayMode(item); const inverted = isUsageInverted(item); const showCursor = isUsageCursorEnabled(item); + const format = resolveNumberFormat('percent', item, settings); if (context.isPreview) { const previewPercent = 4; @@ -71,17 +76,17 @@ export class WeeklyOpusUsageWidget implements Widget { if (isUsageProgressMode(displayMode)) { const width = getUsageProgressBarWidth(displayMode); const progressBar = makeTimerProgressBar(renderedPercent, width, showCursor ? { cursorPercent: 50 } : undefined); - const progressDisplay = `[${progressBar}] ${renderedPercent.toFixed(1)}%`; + const progressDisplay = `[${progressBar}] ${formatPercent(renderedPercent, format)}`; return formatRawOrLabeledValue(item, LABEL, progressDisplay); } if (isUsageSliderMode(displayMode)) { const slider = makeSliderBar(renderedPercent, undefined, showCursor ? { cursorPercent: 50 } : undefined); - const sliderDisplay = displayMode === 'slider' ? `${slider} ${renderedPercent.toFixed(1)}%` : slider; + const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(renderedPercent, format)}` : slider; return formatRawOrLabeledValue(item, LABEL, sliderDisplay); } - return formatRawOrLabeledValue(item, LABEL, `${renderedPercent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, LABEL, formatPercent(renderedPercent, format)); } const data = context.usageData ?? {}; @@ -106,17 +111,17 @@ export class WeeklyOpusUsageWidget implements Widget { const width = getUsageProgressBarWidth(displayMode); const progressBar = makeTimerProgressBar(renderedPercent, width, getCursorOptions()); - const progressDisplay = `[${progressBar}] ${renderedPercent.toFixed(1)}%`; + const progressDisplay = `[${progressBar}] ${formatPercent(renderedPercent, format)}`; return formatRawOrLabeledValue(item, LABEL, progressDisplay); } if (isUsageSliderMode(displayMode)) { const slider = makeSliderBar(renderedPercent, undefined, getCursorOptions()); - const sliderDisplay = displayMode === 'slider' ? `${slider} ${renderedPercent.toFixed(1)}%` : slider; + const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(renderedPercent, format)}` : slider; return formatRawOrLabeledValue(item, LABEL, sliderDisplay); } - return formatRawOrLabeledValue(item, LABEL, `${renderedPercent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, LABEL, formatPercent(renderedPercent, format)); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { diff --git a/src/widgets/WeeklyResetTimer.ts b/src/widgets/WeeklyResetTimer.ts index 1f456a5c..5fdfb582 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); } diff --git a/src/widgets/WeeklySonnetUsage.ts b/src/widgets/WeeklySonnetUsage.ts index 6e1218df..f9ed900b 100644 --- a/src/widgets/WeeklySonnetUsage.ts +++ b/src/widgets/WeeklySonnetUsage.ts @@ -6,6 +6,10 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { + formatPercent, + resolveNumberFormat +} from '../utils/number-format'; import { getUsageErrorMessage, resolveWeeklySonnetUsageWindow @@ -63,6 +67,7 @@ export class WeeklySonnetUsageWidget implements Widget { const displayMode = getUsageDisplayMode(item); const inverted = isUsageInverted(item); const showCursor = isUsageCursorEnabled(item); + const format = resolveNumberFormat('percent', item, settings); if (context.isPreview) { const previewPercent = 8; @@ -71,17 +76,17 @@ export class WeeklySonnetUsageWidget implements Widget { if (isUsageProgressMode(displayMode)) { const width = getUsageProgressBarWidth(displayMode); const progressBar = makeTimerProgressBar(renderedPercent, width, showCursor ? { cursorPercent: 50 } : undefined); - const progressDisplay = `[${progressBar}] ${renderedPercent.toFixed(1)}%`; + const progressDisplay = `[${progressBar}] ${formatPercent(renderedPercent, format)}`; return formatRawOrLabeledValue(item, LABEL, progressDisplay); } if (isUsageSliderMode(displayMode)) { const slider = makeSliderBar(renderedPercent, undefined, showCursor ? { cursorPercent: 50 } : undefined); - const sliderDisplay = displayMode === 'slider' ? `${slider} ${renderedPercent.toFixed(1)}%` : slider; + const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(renderedPercent, format)}` : slider; return formatRawOrLabeledValue(item, LABEL, sliderDisplay); } - return formatRawOrLabeledValue(item, LABEL, `${renderedPercent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, LABEL, formatPercent(renderedPercent, format)); } const data = context.usageData ?? {}; @@ -106,17 +111,17 @@ export class WeeklySonnetUsageWidget implements Widget { const width = getUsageProgressBarWidth(displayMode); const progressBar = makeTimerProgressBar(renderedPercent, width, getCursorOptions()); - const progressDisplay = `[${progressBar}] ${renderedPercent.toFixed(1)}%`; + const progressDisplay = `[${progressBar}] ${formatPercent(renderedPercent, format)}`; return formatRawOrLabeledValue(item, LABEL, progressDisplay); } if (isUsageSliderMode(displayMode)) { const slider = makeSliderBar(renderedPercent, undefined, getCursorOptions()); - const sliderDisplay = displayMode === 'slider' ? `${slider} ${renderedPercent.toFixed(1)}%` : slider; + const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(renderedPercent, format)}` : slider; return formatRawOrLabeledValue(item, LABEL, sliderDisplay); } - return formatRawOrLabeledValue(item, LABEL, `${renderedPercent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, LABEL, formatPercent(renderedPercent, format)); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { diff --git a/src/widgets/WeeklyUsage.ts b/src/widgets/WeeklyUsage.ts index 7523e381..bd3ab105 100644 --- a/src/widgets/WeeklyUsage.ts +++ b/src/widgets/WeeklyUsage.ts @@ -6,6 +6,10 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { + formatPercent, + resolveNumberFormat +} from '../utils/number-format'; import { getUsageErrorMessage, resolveWeeklyUsageWindow @@ -61,6 +65,7 @@ export class WeeklyUsageWidget implements Widget { const displayMode = getUsageDisplayMode(item); const inverted = isUsageInverted(item); const showCursor = isUsageCursorEnabled(item); + const format = resolveNumberFormat('percent', item, settings); if (context.isPreview) { const previewPercent = 12; @@ -69,17 +74,17 @@ export class WeeklyUsageWidget implements Widget { if (isUsageProgressMode(displayMode)) { const width = getUsageProgressBarWidth(displayMode); const progressBar = makeTimerProgressBar(renderedPercent, width, showCursor ? { cursorPercent: 50 } : undefined); - const progressDisplay = `[${progressBar}] ${renderedPercent.toFixed(1)}%`; + const progressDisplay = `[${progressBar}] ${formatPercent(renderedPercent, format)}`; return formatRawOrLabeledValue(item, 'Weekly: ', progressDisplay); } if (isUsageSliderMode(displayMode)) { const slider = makeSliderBar(renderedPercent, undefined, showCursor ? { cursorPercent: 50 } : undefined); - const sliderDisplay = displayMode === 'slider' ? `${slider} ${renderedPercent.toFixed(1)}%` : slider; + const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(renderedPercent, format)}` : slider; return formatRawOrLabeledValue(item, 'Weekly: ', sliderDisplay); } - return formatRawOrLabeledValue(item, 'Weekly: ', `${renderedPercent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, 'Weekly: ', formatPercent(renderedPercent, format)); } const data = context.usageData ?? {}; @@ -104,17 +109,17 @@ export class WeeklyUsageWidget implements Widget { const width = getUsageProgressBarWidth(displayMode); const progressBar = makeTimerProgressBar(renderedPercent, width, getCursorOptions()); - const progressDisplay = `[${progressBar}] ${renderedPercent.toFixed(1)}%`; + const progressDisplay = `[${progressBar}] ${formatPercent(renderedPercent, format)}`; return formatRawOrLabeledValue(item, 'Weekly: ', progressDisplay); } if (isUsageSliderMode(displayMode)) { const slider = makeSliderBar(renderedPercent, undefined, getCursorOptions()); - const sliderDisplay = displayMode === 'slider' ? `${slider} ${renderedPercent.toFixed(1)}%` : slider; + const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(renderedPercent, format)}` : slider; return formatRawOrLabeledValue(item, 'Weekly: ', sliderDisplay); } - return formatRawOrLabeledValue(item, 'Weekly: ', `${renderedPercent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, 'Weekly: ', formatPercent(renderedPercent, format)); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { diff --git a/src/widgets/shared/cache-metrics.ts b/src/widgets/shared/cache-metrics.ts index 5a8f6b19..2100a188 100644 --- a/src/widgets/shared/cache-metrics.ts +++ b/src/widgets/shared/cache-metrics.ts @@ -1,8 +1,10 @@ +import type { NumberFormat } from '../../types/NumberFormat'; import type { RenderContext } from '../../types/RenderContext'; import { getContextWindowTurnCacheTokens, type TurnCacheTokens } from '../../utils/context-window'; +import { formatPercent } from '../../utils/number-format'; import { formatTokens } from '../../utils/renderer'; // Resolves the cache token triple for either scope: @@ -46,7 +48,12 @@ export function getCacheWritePercentage(tokens: TurnCacheTokens): number | null // Combines a token count with its context share: "88.0k (84.5%)". // Falls back to the bare token count when the percentage is undefined. -export function formatTokensWithPercentage(tokenCount: number, percentage: number | null): string { - const tokens = formatTokens(tokenCount); - return percentage === null ? tokens : `${tokens} (${percentage.toFixed(1)}%)`; +export function formatTokensWithPercentage( + tokenCount: number, + percentage: number | null, + tokenFormat: NumberFormat = {}, + percentFormat: NumberFormat = {} +): string { + const tokens = formatTokens(tokenCount, tokenFormat); + return percentage === null ? tokens : `${tokens} (${formatPercent(percentage, percentFormat)})`; } diff --git a/src/widgets/shared/context-slider.ts b/src/widgets/shared/context-slider.ts index 9157b0e8..253bf8c9 100644 --- a/src/widgets/shared/context-slider.ts +++ b/src/widgets/shared/context-slider.ts @@ -1,7 +1,9 @@ +import type { NumberFormat } from '../../types/NumberFormat'; import type { CustomKeybind, WidgetItem } from '../../types/Widget'; +import { formatPercent } from '../../utils/number-format'; import { makeSliderBar } from './usage-display'; @@ -43,13 +45,13 @@ export function cycleContextSliderMode(item: WidgetItem): WidgetItem { }; } -export function renderContextSlider(mode: ContextSliderMode, percent: number): string | null { +export function renderContextSlider(mode: ContextSliderMode, percent: number, format: NumberFormat = {}): string | null { if (mode === 'none') { return null; } const slider = makeSliderBar(percent); if (mode === 'slider') { - return `${slider} ${percent.toFixed(1)}%`; + return `${slider} ${formatPercent(percent, format)}`; } return slider; } diff --git a/src/widgets/shared/speed-widget.tsx b/src/widgets/shared/speed-widget.tsx index efaf313f..e0ad70bd 100644 --- a/src/widgets/shared/speed-widget.tsx +++ b/src/widgets/shared/speed-widget.tsx @@ -6,6 +6,7 @@ import { import React, { useState } from 'react'; import type { RenderContext } from '../../types/RenderContext'; +import type { Settings } from '../../types/Settings'; import type { SpeedMetrics } from '../../types/SpeedMetrics'; import type { CustomKeybind, @@ -14,6 +15,7 @@ import type { WidgetItem } from '../../types/Widget'; import { shouldInsertInput } from '../../utils/input-guards'; +import { resolveNumberFormat } from '../../utils/number-format'; import { calculateInputSpeed, calculateOutputSpeed, @@ -110,14 +112,14 @@ export function getSpeedWidgetEditorDisplay(kind: SpeedWidgetKind, item: WidgetI export function renderSpeedWidgetValue( kind: SpeedWidgetKind, item: WidgetItem, - context: RenderContext + context: RenderContext, + settings: Settings ): string | null { const config = SPEED_WIDGET_CONFIG[kind]; - const previewValue = isWidgetSpeedWindowEnabled(item) - ? config.windowedPreview - : config.sessionPreview; + const format = resolveNumberFormat('speed', item, settings); if (context.isPreview) { + const previewValue = isWidgetSpeedWindowEnabled(item) ? config.windowedPreview : config.sessionPreview; return formatRawOrLabeledValue(item, config.label, previewValue); } @@ -127,7 +129,7 @@ export function renderSpeedWidgetValue( } const speed = calculateSpeed(kind, metrics); - return formatRawOrLabeledValue(item, config.label, formatSpeed(speed)); + return formatRawOrLabeledValue(item, config.label, formatSpeed(speed, format)); } export function getSpeedWidgetCustomKeybinds(): CustomKeybind[] { From 1f74b0453d99bd67f14b2184286bf71be40560e6 Mon Sep 17 00:00:00 2001 From: Zach Date: Wed, 5 Aug 2026 14:17:00 -0500 Subject: [PATCH 2/9] refactor(number-format): move the precision cycle to the line editor Widgets declare supportsNumberFormat(), matching the existing supportsRawValue / supportsColors convention, and the items editor injects a shared precision keybind for any widget that returns true. The cycle itself moves to utils/number-format.ts so all number-format logic lives in one module. This keeps every non-color override on the line editor screen, where the rest of them already live, and keeps precision reachable while a powerline theme is active. The color menu goes back to color, bold and dim only. The keybind is '.', reading as the decimal point, because every letter is already taken by a widget bind or an editor-level one. The items-editor dispatcher no longer bails early for widgets that expose no keybinds of their own, so plain numeric widgets receive the injected bind. Fable Weekly Usage, which landed since this branch opened, is threaded through formatPercent too. --- src/tui/components/ColorMenu.tsx | 16 +----- src/tui/components/ItemsEditor.tsx | 10 ++-- .../color-menu/__tests__/mutations.test.ts | 31 ----------- src/tui/components/color-menu/mutations.ts | 36 ------------- .../__tests__/input-handlers.test.ts | 50 +++++++++++++++++- .../components/items-editor/input-handlers.ts | 14 ++++- src/types/Widget.ts | 4 ++ src/utils/__tests__/number-format.test.ts | 51 +++++++++++++++++++ src/utils/number-format.ts | 50 +++++++++++++++++- src/widgets/BlockResetTimer.ts | 1 + src/widgets/BlockTimer.ts | 1 + src/widgets/CacheHitRate.ts | 1 + src/widgets/CacheRead.ts | 1 + src/widgets/CacheWrite.ts | 1 + src/widgets/CompactionCounter.ts | 1 + src/widgets/ContextBar.ts | 1 + src/widgets/ContextLength.ts | 1 + src/widgets/ContextPercentage.ts | 1 + src/widgets/ContextPercentageUsable.ts | 1 + src/widgets/ContextWindow.ts | 1 + src/widgets/ExtraUsageUtilization.ts | 1 + src/widgets/FableWeeklyUsage.ts | 18 ++++--- src/widgets/FreeMemory.ts | 1 + src/widgets/InputSpeed.ts | 1 + src/widgets/OutputSpeed.ts | 1 + src/widgets/SessionCost.ts | 1 + src/widgets/SessionUsage.ts | 1 + src/widgets/TokensCached.ts | 1 + src/widgets/TokensInput.ts | 1 + src/widgets/TokensOutput.ts | 1 + src/widgets/TokensTotal.ts | 1 + src/widgets/TotalSpeed.ts | 1 + src/widgets/WeeklyOpusUsage.ts | 1 + src/widgets/WeeklyResetTimer.ts | 1 + src/widgets/WeeklySonnetUsage.ts | 1 + src/widgets/WeeklyUsage.ts | 1 + 36 files changed, 210 insertions(+), 96 deletions(-) diff --git a/src/tui/components/ColorMenu.tsx b/src/tui/components/ColorMenu.tsx index af239267..41a941b4 100644 --- a/src/tui/components/ColorMenu.tsx +++ b/src/tui/components/ColorMenu.tsx @@ -24,7 +24,6 @@ import { clearAllWidgetStyling, cycleWidgetColor, cycleWidgetDim, - cycleWidgetNumberStyle, resetWidgetStyling, setWidgetColor, toggleWidgetBold @@ -279,15 +278,6 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin onUpdate(newItems); } } - } else if (input === 'n' || input === 'N') { - if (highlightedItemId && highlightedItemId !== 'back') { - // Cycle number format style for the highlighted item: default -> compact -> whole -> default - const selectedWidget = colorableWidgets.find(widget => widget.id === highlightedItemId); - if (selectedWidget) { - const newItems = cycleWidgetNumberStyle(widgets, selectedWidget.id); - onUpdate(newItems); - } - } } else if (input === 'r' || input === 'R') { if (highlightedItemId && highlightedItemId !== 'back') { // Reset all styling (color, background, and bold) for the highlighted item @@ -454,9 +444,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin const styleIndicators = [ selectedWidget?.bold ? '[BOLD]' : null, selectedWidget?.dim === true ? '[DIM]' : null, - selectedWidget?.dim === 'parens' ? '[DIM ()]' : null, - selectedWidget?.numberFormat?.style ? `[#${selectedWidget.numberFormat.style}]` : null, - selectedWidget?.numberFormat?.decimals !== undefined ? `[#${selectedWidget.numberFormat.decimals}dp]` : null + selectedWidget?.dim === 'parens' ? '[DIM ()]' : null ].filter(indicator => indicator !== null).join(' '); // Gradient selection mode takes over the whole view @@ -600,7 +588,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin ↑↓ to select, ←→ to cycle {' '} {editingBackground ? 'background' : 'foreground'} - , (f) to toggle bg/fg, (b)old, (d)im, (n)umber, + , (f) to toggle bg/fg, (b)old, (d)im, {settings.colorLevel === 3 ? ' (h)ex,' : settings.colorLevel === 2 ? ' (a)nsi256,' : ''} {!editingBackground && settings.colorLevel >= 2 ? ' (g)radient,' : ''} {' '} diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index b9257ec3..1d3d384e 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -14,6 +14,7 @@ import type { } from '../../types/Widget'; import { getBackgroundColorsForPowerline } from '../../utils/colors'; import { generateGuid } from '../../utils/guid'; +import { getNumberFormatKeybind } from '../../utils/number-format'; import { canDetectTerminalWidth } from '../../utils/terminal'; import { filterWidgetCatalog, @@ -104,11 +105,12 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB }; const getCustomKeybindsForWidget = (widgetImpl: Widget, widget: WidgetItem): CustomKeybind[] => { - if (!widgetImpl.getCustomKeybinds) { - return []; - } + const keybinds = widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : []; - return widgetImpl.getCustomKeybinds(widget); + // Numeric widgets get the precision cycle here rather than in the color + // menu, so every non-color override stays on this screen and stays + // reachable while a powerline theme is active. + return widgetImpl.supportsNumberFormat?.() ? [...keybinds, getNumberFormatKeybind()] : keybinds; }; const openWidgetPicker = (action: WidgetPickerAction) => { diff --git a/src/tui/components/color-menu/__tests__/mutations.test.ts b/src/tui/components/color-menu/__tests__/mutations.test.ts index 63a39cee..2a506f9b 100644 --- a/src/tui/components/color-menu/__tests__/mutations.test.ts +++ b/src/tui/components/color-menu/__tests__/mutations.test.ts @@ -9,7 +9,6 @@ import { clearAllWidgetStyling, cycleWidgetColor, cycleWidgetDim, - cycleWidgetNumberStyle, resetWidgetStyling, toggleWidgetBold, updateWidgetById @@ -59,36 +58,6 @@ describe('color-menu mutations', () => { expect(whole[1]?.dim).toBeUndefined(); }); - it('cycleWidgetNumberStyle cycles default, compact, whole, then default for the selected widget only', () => { - const widgets: WidgetItem[] = [ - { id: '1', type: 'tokens-input' }, - { id: '2', type: 'tokens-output' } - ]; - - const compact = cycleWidgetNumberStyle(widgets, '1'); - const whole = cycleWidgetNumberStyle(compact, '1'); - const off = cycleWidgetNumberStyle(whole, '1'); - - expect(compact[0]?.numberFormat).toEqual({ style: 'compact' }); - expect(whole[0]?.numberFormat).toEqual({ style: 'whole' }); - expect(off[0]).toEqual({ id: '1', type: 'tokens-input' }); - expect(compact[1]?.numberFormat).toBeUndefined(); - }); - - it('cycleWidgetNumberStyle preserves an explicit decimals across the cycle', () => { - const widgets: WidgetItem[] = [ - { id: '1', type: 'tokens-input', numberFormat: { decimals: 2 } } - ]; - - const compact = cycleWidgetNumberStyle(widgets, '1'); - const whole = cycleWidgetNumberStyle(compact, '1'); - const off = cycleWidgetNumberStyle(whole, '1'); - - expect(compact[0]?.numberFormat).toEqual({ style: 'compact', decimals: 2 }); - expect(whole[0]?.numberFormat).toEqual({ style: 'whole', decimals: 2 }); - expect(off[0]?.numberFormat).toEqual({ decimals: 2 }); - }); - it('resetWidgetStyling removes color, backgroundColor, bold, dim, and numberFormat from one widget', () => { const widgets: WidgetItem[] = [ { diff --git a/src/tui/components/color-menu/mutations.ts b/src/tui/components/color-menu/mutations.ts index b5509b6c..ce13a3b9 100644 --- a/src/tui/components/color-menu/mutations.ts +++ b/src/tui/components/color-menu/mutations.ts @@ -1,7 +1,3 @@ -import type { - NumberFormat, - NumberStyle -} from '../../../types/NumberFormat'; import type { WidgetItem } from '../../../types/Widget'; import { getWidget } from '../../../utils/widgets'; @@ -64,38 +60,6 @@ export function cycleWidgetDim(widgets: WidgetItem[], widgetId: string): WidgetI }); } -export function cycleWidgetNumberStyle(widgets: WidgetItem[], widgetId: string): WidgetItem[] { - return updateWidgetById(widgets, widgetId, (widget) => { - // Cycle the number style: default (precise) -> compact -> whole -> default. - // Any explicit `decimals` is preserved across the cycle. - const currentStyle = widget.numberFormat?.style; - const nextStyle: NumberStyle | undefined = currentStyle === undefined - ? 'compact' - : currentStyle === 'compact' - ? 'whole' - : undefined; - const decimals = widget.numberFormat?.decimals; - - if (nextStyle === undefined && decimals === undefined) { - const { numberFormat, ...restWidget } = widget; - void numberFormat; // Intentionally unused - return restWidget; - } - - const nextFormat: NumberFormat = {}; - if (nextStyle !== undefined) { - nextFormat.style = nextStyle; - } - if (decimals !== undefined) { - nextFormat.decimals = decimals; - } - return { - ...widget, - numberFormat: nextFormat - }; - }); -} - export function resetWidgetStyling(widgets: WidgetItem[], widgetId: string): WidgetItem[] { return updateWidgetById(widgets, widgetId, (widget) => { const { diff --git a/src/tui/components/items-editor/__tests__/input-handlers.test.ts b/src/tui/components/items-editor/__tests__/input-handlers.test.ts index a811881d..7fb32221 100644 --- a/src/tui/components/items-editor/__tests__/input-handlers.test.ts +++ b/src/tui/components/items-editor/__tests__/input-handlers.test.ts @@ -5,7 +5,12 @@ import { vi } from 'vitest'; -import type { WidgetItem } from '../../../../types/Widget'; +import type { + CustomKeybind, + Widget, + WidgetItem +} from '../../../../types/Widget'; +import { getNumberFormatKeybind } from '../../../../utils/number-format'; import type { WidgetCatalogEntry } from '../../../../utils/widgets'; import { handleMoveInputMode, @@ -1003,4 +1008,47 @@ describe('items-editor input handlers', () => { expect(setSelectedIndex).not.toHaveBeenCalled(); }); }); + + describe('precision keybind', () => { + // The items editor injects this bind for numeric widgets, so the handler + // applies it itself rather than delegating to handleEditorAction. + const injectPrecisionKeybind = (widgetImpl: Widget, widget: WidgetItem): CustomKeybind[] => { + const keybinds = widgetImpl.getCustomKeybinds ? widgetImpl.getCustomKeybinds(widget) : []; + return widgetImpl.supportsNumberFormat?.() ? [...keybinds, getNumberFormatKeybind()] : keybinds; + }; + + const pressPrecision = (widgets: WidgetItem[], onUpdate: (widgets: WidgetItem[]) => void) => { + handleNormalInputMode({ + input: '.', + key: {}, + widgets, + selectedIndex: 0, + separatorChars: ['|'], + onBack: vi.fn(), + onUpdate, + setSelectedIndex: vi.fn(), + setMoveMode: vi.fn(), + setShowClearConfirm: vi.fn(), + openWidgetPicker: vi.fn(), + getCustomKeybindsForWidget: injectPrecisionKeybind, + setCustomEditorWidget: vi.fn() + }); + }; + + it('cycles the number style of a numeric widget that has no keybinds of its own', () => { + const onUpdate = vi.fn(); + pressPrecision([{ id: '1', type: 'tokens-input' }], onUpdate); + + expect(onUpdate).toHaveBeenCalledWith([ + { id: '1', type: 'tokens-input', numberFormat: { style: 'compact' } } + ]); + }); + + it('leaves a non-numeric widget untouched', () => { + const onUpdate = vi.fn(); + pressPrecision([{ id: '1', type: 'custom-text' }], onUpdate); + + expect(onUpdate).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/tui/components/items-editor/input-handlers.ts b/src/tui/components/items-editor/input-handlers.ts index fab43416..12ed9e3d 100644 --- a/src/tui/components/items-editor/input-handlers.ts +++ b/src/tui/components/items-editor/input-handlers.ts @@ -5,6 +5,10 @@ import type { WidgetItemType } from '../../../types/Widget'; import { generateGuid } from '../../../utils/guid'; +import { + CYCLE_NUMBER_STYLE_ACTION, + cycleNumberStyle +} from '../../../utils/number-format'; import { filterWidgetCatalog, getWidget, @@ -476,7 +480,7 @@ export function handleNormalInputMode({ const currentWidget = widgets[selectedIndex]; if (currentWidget && currentWidget.type !== 'separator' && currentWidget.type !== 'flex-separator') { const widgetImpl = getWidget(currentWidget.type); - if (!widgetImpl?.getCustomKeybinds) { + if (!widgetImpl) { return; } @@ -484,7 +488,13 @@ export function handleNormalInputMode({ const matchedKeybind = customKeybinds.find(kb => kb.key === input); if (matchedKeybind && !key.ctrl) { - if (widgetImpl.handleEditorAction) { + // The precision cycle is shared by every numeric widget, so it is + // applied here instead of in each widget's handleEditorAction. + if (matchedKeybind.action === CYCLE_NUMBER_STYLE_ACTION) { + const newWidgets = [...widgets]; + newWidgets[selectedIndex] = cycleNumberStyle(currentWidget); + onUpdate(newWidgets); + } else if (widgetImpl.handleEditorAction) { const updatedWidget = widgetImpl.handleEditorAction(matchedKeybind.action, currentWidget); if (updatedWidget) { const newWidgets = [...widgets]; diff --git a/src/types/Widget.ts b/src/types/Widget.ts index 198f7379..de44725e 100644 --- a/src/types/Widget.ts +++ b/src/types/Widget.ts @@ -47,6 +47,10 @@ export interface Widget { renderEditor?(props: WidgetEditorProps): React.ReactElement | null; supportsRawValue(): boolean; supportsColors(item: WidgetItem): boolean; + // Whether the widget renders a number whose precision can be overridden. + // Gates the items editor's precision keybind; widgets that omit it are + // treated as non-numeric. + supportsNumberFormat?(): boolean; handleEditorAction?(action: string, item: WidgetItem): WidgetItem | null; getNumericValue?(context: RenderContext, item: WidgetItem): number | null; } diff --git a/src/utils/__tests__/number-format.test.ts b/src/utils/__tests__/number-format.test.ts index f7281f60..5751d373 100644 --- a/src/utils/__tests__/number-format.test.ts +++ b/src/utils/__tests__/number-format.test.ts @@ -12,8 +12,11 @@ import { DEFAULT_SETTINGS } from '../../types/Settings'; import type { WidgetItem } from '../../types/Widget'; import { formatTokens } from '../format-tokens'; import { + CYCLE_NUMBER_STYLE_ACTION, + cycleNumberStyle, formatCost, formatPercent, + getNumberFormatKeybind, renderMagnitude, resolveNumberFormat } from '../number-format'; @@ -114,6 +117,54 @@ describe('formatTokens with a format', () => { }); }); +describe('cycleNumberStyle', () => { + it('cycles default, compact, whole, then back to default', () => { + const item: WidgetItem = { id: '1', type: 'tokens-input' }; + + const compact = cycleNumberStyle(item); + const whole = cycleNumberStyle(compact); + const off = cycleNumberStyle(whole); + + expect(compact.numberFormat).toEqual({ style: 'compact' }); + expect(whole.numberFormat).toEqual({ style: 'whole' }); + expect(off).toEqual({ id: '1', type: 'tokens-input' }); + }); + + it('preserves an explicit decimals across the cycle', () => { + const item: WidgetItem = { id: '1', type: 'tokens-input', numberFormat: { decimals: 2 } }; + + const compact = cycleNumberStyle(item); + const whole = cycleNumberStyle(compact); + const off = cycleNumberStyle(whole); + + expect(compact.numberFormat).toEqual({ style: 'compact', decimals: 2 }); + expect(whole.numberFormat).toEqual({ style: 'whole', decimals: 2 }); + expect(off.numberFormat).toEqual({ decimals: 2 }); + }); + + it('leaves other widget fields untouched', () => { + const item: WidgetItem = { id: '1', type: 'tokens-input', color: 'blue', bold: true }; + + expect(cycleNumberStyle(item)).toEqual({ + id: '1', + type: 'tokens-input', + color: 'blue', + bold: true, + numberFormat: { style: 'compact' } + }); + }); +}); + +describe('getNumberFormatKeybind', () => { + it('binds the precision cycle to a key no widget uses', () => { + expect(getNumberFormatKeybind()).toEqual({ + key: '.', + label: '(.) precision', + action: CYCLE_NUMBER_STYLE_ACTION + }); + }); +}); + describe('formatSpeed with a format', () => { it('compact trims trailing zeros', () => { expect(formatSpeed(1000, { style: 'compact' })).toBe('1k t/s'); diff --git a/src/utils/number-format.ts b/src/utils/number-format.ts index 4aa0967c..92e1c504 100644 --- a/src/utils/number-format.ts +++ b/src/utils/number-format.ts @@ -1,9 +1,55 @@ import type { NumberFormat, - NumberKind + NumberKind, + NumberStyle } from '../types/NumberFormat'; import type { Settings } from '../types/Settings'; -import type { WidgetItem } from '../types/Widget'; +import type { + CustomKeybind, + WidgetItem +} from '../types/Widget'; + +// Editor action and keybind for the per-widget precision cycle. The items editor +// injects this for any widget whose supportsNumberFormat() is true, so a numeric +// widget needs no keybind wiring of its own. '.' reads as the decimal point and +// keeps every letter free for widget-specific binds. +export const CYCLE_NUMBER_STYLE_ACTION = 'cycle-number-style'; +const NUMBER_FORMAT_KEYBIND: CustomKeybind = { key: '.', label: '(.) precision', action: CYCLE_NUMBER_STYLE_ACTION }; + +export function getNumberFormatKeybind(): CustomKeybind { + return NUMBER_FORMAT_KEYBIND; +} + +// Cycle the number style: default (precise) -> compact -> whole -> default. +// Any explicit `decimals` is preserved across the cycle. +export function cycleNumberStyle(item: WidgetItem): WidgetItem { + const currentStyle = item.numberFormat?.style; + const nextStyle: NumberStyle | undefined = currentStyle === undefined + ? 'compact' + : currentStyle === 'compact' + ? 'whole' + : undefined; + const decimals = item.numberFormat?.decimals; + + if (nextStyle === undefined && decimals === undefined) { + const { numberFormat, ...restItem } = item; + void numberFormat; // Intentionally unused + return restItem; + } + + const nextFormat: NumberFormat = {}; + if (nextStyle !== undefined) { + nextFormat.style = nextStyle; + } + if (decimals !== undefined) { + nextFormat.decimals = decimals; + } + + return { + ...item, + numberFormat: nextFormat + }; +} // Strip a pointless trailing ".0" (or ".00", ...) so a whole value reads cleanly // while a meaningful fraction is left intact. diff --git a/src/widgets/BlockResetTimer.ts b/src/widgets/BlockResetTimer.ts index 807d9183..736601f1 100644 --- a/src/widgets/BlockResetTimer.ts +++ b/src/widgets/BlockResetTimer.ts @@ -192,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 1fa53357..0a2f0625 100644 --- a/src/widgets/BlockTimer.ts +++ b/src/widgets/BlockTimer.ts @@ -134,4 +134,5 @@ export class BlockTimerWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/CacheHitRate.ts b/src/widgets/CacheHitRate.ts index 900c5a3f..46627f21 100644 --- a/src/widgets/CacheHitRate.ts +++ b/src/widgets/CacheHitRate.ts @@ -67,4 +67,5 @@ export class CacheHitRateWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/CacheRead.ts b/src/widgets/CacheRead.ts index 54b98166..71a3e3af 100644 --- a/src/widgets/CacheRead.ts +++ b/src/widgets/CacheRead.ts @@ -62,4 +62,5 @@ export class CacheReadWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/CacheWrite.ts b/src/widgets/CacheWrite.ts index d9f8b663..73aac7b2 100644 --- a/src/widgets/CacheWrite.ts +++ b/src/widgets/CacheWrite.ts @@ -62,4 +62,5 @@ export class CacheWriteWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/CompactionCounter.ts b/src/widgets/CompactionCounter.ts index e95e3829..c031ad58 100644 --- a/src/widgets/CompactionCounter.ts +++ b/src/widgets/CompactionCounter.ts @@ -295,4 +295,5 @@ export class CompactionCounterWidget implements Widget { supportsRawValue(): boolean { return false; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/ContextBar.ts b/src/widgets/ContextBar.ts index 89a2454a..55122d16 100644 --- a/src/widgets/ContextBar.ts +++ b/src/widgets/ContextBar.ts @@ -141,4 +141,5 @@ export class ContextBarWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/ContextLength.ts b/src/widgets/ContextLength.ts index 92575f71..5416b387 100644 --- a/src/widgets/ContextLength.ts +++ b/src/widgets/ContextLength.ts @@ -37,4 +37,5 @@ export class ContextLengthWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/ContextPercentage.ts b/src/widgets/ContextPercentage.ts index 69aff8b3..371a0136 100644 --- a/src/widgets/ContextPercentage.ts +++ b/src/widgets/ContextPercentage.ts @@ -82,4 +82,5 @@ export class ContextPercentageWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/ContextPercentageUsable.ts b/src/widgets/ContextPercentageUsable.ts index e5dc14e0..9e8c65b3 100644 --- a/src/widgets/ContextPercentageUsable.ts +++ b/src/widgets/ContextPercentageUsable.ts @@ -94,4 +94,5 @@ export class ContextPercentageUsableWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/ContextWindow.ts b/src/widgets/ContextWindow.ts index 51d12e9f..698b5d8e 100644 --- a/src/widgets/ContextWindow.ts +++ b/src/widgets/ContextWindow.ts @@ -44,4 +44,5 @@ export class ContextWindowWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/ExtraUsageUtilization.ts b/src/widgets/ExtraUsageUtilization.ts index cbb8bbb1..174788d0 100644 --- a/src/widgets/ExtraUsageUtilization.ts +++ b/src/widgets/ExtraUsageUtilization.ts @@ -127,4 +127,5 @@ export class ExtraUsageUtilizationWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/FableWeeklyUsage.ts b/src/widgets/FableWeeklyUsage.ts index ffc52622..d34d8fb1 100644 --- a/src/widgets/FableWeeklyUsage.ts +++ b/src/widgets/FableWeeklyUsage.ts @@ -6,6 +6,10 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { + formatPercent, + resolveNumberFormat +} from '../utils/number-format'; import { getUsageErrorMessage, resolveFableUsageWindow @@ -60,6 +64,7 @@ export class FableWeeklyUsageWidget implements Widget { } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + const format = resolveNumberFormat('percent', item, settings); const displayMode = getUsageDisplayMode(item); const inverted = isUsageInverted(item); const showCursor = isUsageCursorEnabled(item); @@ -71,17 +76,17 @@ export class FableWeeklyUsageWidget implements Widget { if (isUsageProgressMode(displayMode)) { const width = getUsageProgressBarWidth(displayMode); const progressBar = makeTimerProgressBar(renderedPercent, width, showCursor ? { cursorPercent: 50 } : undefined); - const progressDisplay = `[${progressBar}] ${renderedPercent.toFixed(1)}%`; + const progressDisplay = `[${progressBar}] ${formatPercent(renderedPercent, format)}`; return formatRawOrLabeledValue(item, LABEL, progressDisplay); } if (isUsageSliderMode(displayMode)) { const slider = makeSliderBar(renderedPercent, undefined, showCursor ? { cursorPercent: 50 } : undefined); - const sliderDisplay = displayMode === 'slider' ? `${slider} ${renderedPercent.toFixed(1)}%` : slider; + const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(renderedPercent, format)}` : slider; return formatRawOrLabeledValue(item, LABEL, sliderDisplay); } - return formatRawOrLabeledValue(item, LABEL, `${renderedPercent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, LABEL, formatPercent(renderedPercent, format)); } const data = context.usageData ?? {}; @@ -106,17 +111,17 @@ export class FableWeeklyUsageWidget implements Widget { const width = getUsageProgressBarWidth(displayMode); const progressBar = makeTimerProgressBar(renderedPercent, width, getCursorOptions()); - const progressDisplay = `[${progressBar}] ${renderedPercent.toFixed(1)}%`; + const progressDisplay = `[${progressBar}] ${formatPercent(renderedPercent, format)}`; return formatRawOrLabeledValue(item, LABEL, progressDisplay); } if (isUsageSliderMode(displayMode)) { const slider = makeSliderBar(renderedPercent, undefined, getCursorOptions()); - const sliderDisplay = displayMode === 'slider' ? `${slider} ${renderedPercent.toFixed(1)}%` : slider; + const sliderDisplay = displayMode === 'slider' ? `${slider} ${formatPercent(renderedPercent, format)}` : slider; return formatRawOrLabeledValue(item, LABEL, sliderDisplay); } - return formatRawOrLabeledValue(item, LABEL, `${renderedPercent.toFixed(1)}%`); + return formatRawOrLabeledValue(item, LABEL, formatPercent(renderedPercent, format)); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { @@ -125,4 +130,5 @@ export class FableWeeklyUsageWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/FreeMemory.ts b/src/widgets/FreeMemory.ts index 2d865f80..b86c9396 100644 --- a/src/widgets/FreeMemory.ts +++ b/src/widgets/FreeMemory.ts @@ -99,4 +99,5 @@ export class FreeMemoryWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/InputSpeed.ts b/src/widgets/InputSpeed.ts index 70321586..bde6d216 100644 --- a/src/widgets/InputSpeed.ts +++ b/src/widgets/InputSpeed.ts @@ -40,4 +40,5 @@ export class InputSpeedWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/OutputSpeed.ts b/src/widgets/OutputSpeed.ts index 43a30a9c..14111878 100644 --- a/src/widgets/OutputSpeed.ts +++ b/src/widgets/OutputSpeed.ts @@ -40,4 +40,5 @@ export class OutputSpeedWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/SessionCost.ts b/src/widgets/SessionCost.ts index 684c8054..cce06114 100644 --- a/src/widgets/SessionCost.ts +++ b/src/widgets/SessionCost.ts @@ -37,4 +37,5 @@ export class SessionCostWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/SessionUsage.ts b/src/widgets/SessionUsage.ts index 06418ae5..b8c22b52 100644 --- a/src/widgets/SessionUsage.ts +++ b/src/widgets/SessionUsage.ts @@ -128,4 +128,5 @@ export class SessionUsageWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/TokensCached.ts b/src/widgets/TokensCached.ts index 37d62664..e9a0bf30 100644 --- a/src/widgets/TokensCached.ts +++ b/src/widgets/TokensCached.ts @@ -33,4 +33,5 @@ export class TokensCachedWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/TokensInput.ts b/src/widgets/TokensInput.ts index 6e672f09..27c3007d 100644 --- a/src/widgets/TokensInput.ts +++ b/src/widgets/TokensInput.ts @@ -39,4 +39,5 @@ export class TokensInputWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/TokensOutput.ts b/src/widgets/TokensOutput.ts index 92c0b30e..5c69e05e 100644 --- a/src/widgets/TokensOutput.ts +++ b/src/widgets/TokensOutput.ts @@ -39,4 +39,5 @@ export class TokensOutputWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/TokensTotal.ts b/src/widgets/TokensTotal.ts index 15aa9bb0..10784906 100644 --- a/src/widgets/TokensTotal.ts +++ b/src/widgets/TokensTotal.ts @@ -33,4 +33,5 @@ export class TokensTotalWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/TotalSpeed.ts b/src/widgets/TotalSpeed.ts index 9fa950c5..251e53b6 100644 --- a/src/widgets/TotalSpeed.ts +++ b/src/widgets/TotalSpeed.ts @@ -40,4 +40,5 @@ export class TotalSpeedWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/WeeklyOpusUsage.ts b/src/widgets/WeeklyOpusUsage.ts index b1f96035..ebfe48aa 100644 --- a/src/widgets/WeeklyOpusUsage.ts +++ b/src/widgets/WeeklyOpusUsage.ts @@ -130,4 +130,5 @@ export class WeeklyOpusUsageWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/WeeklyResetTimer.ts b/src/widgets/WeeklyResetTimer.ts index 5fdfb582..1c1f670c 100644 --- a/src/widgets/WeeklyResetTimer.ts +++ b/src/widgets/WeeklyResetTimer.ts @@ -285,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 f9ed900b..52a6b0c5 100644 --- a/src/widgets/WeeklySonnetUsage.ts +++ b/src/widgets/WeeklySonnetUsage.ts @@ -130,4 +130,5 @@ export class WeeklySonnetUsageWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } diff --git a/src/widgets/WeeklyUsage.ts b/src/widgets/WeeklyUsage.ts index bd3ab105..0e41453d 100644 --- a/src/widgets/WeeklyUsage.ts +++ b/src/widgets/WeeklyUsage.ts @@ -128,4 +128,5 @@ export class WeeklyUsageWidget implements Widget { supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } + supportsNumberFormat(): boolean { return true; } } From 22a74f1757cdc98a6824528248176c47cbe82d20 Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Thu, 3 Sep 2026 12:48:56 -0400 Subject: [PATCH 3/9] fix: preserve zero cost hiding across formats --- src/widgets/SessionCost.ts | 8 +++++--- src/widgets/__tests__/SessionCost.test.ts | 13 +++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/widgets/SessionCost.ts b/src/widgets/SessionCost.ts index b07233ee..b415d740 100644 --- a/src/widgets/SessionCost.ts +++ b/src/widgets/SessionCost.ts @@ -39,12 +39,14 @@ export class SessionCostWidget implements Widget { return null; } - const formattedCost = formatCost(totalCost, format); - - 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}`; } diff --git a/src/widgets/__tests__/SessionCost.test.ts b/src/widgets/__tests__/SessionCost.test.ts index 695e0389..467af082 100644 --- a/src/widgets/__tests__/SessionCost.test.ts +++ b/src/widgets/__tests__/SessionCost.test.ts @@ -55,4 +55,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(); + }); }); From 80de0ce2320556feacc459ead2a009b5a7234383 Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Thu, 3 Sep 2026 12:50:27 -0400 Subject: [PATCH 4/9] fix: advance explicit precise number styles --- src/tui/components/GlobalOverridesMenu.tsx | 9 +++------ src/utils/__tests__/number-format.test.ts | 12 ++++++++++++ src/utils/number-format.ts | 17 +++++++++++------ 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/src/tui/components/GlobalOverridesMenu.tsx b/src/tui/components/GlobalOverridesMenu.tsx index defdc6ce..1430f2c1 100644 --- a/src/tui/components/GlobalOverridesMenu.tsx +++ b/src/tui/components/GlobalOverridesMenu.tsx @@ -10,8 +10,7 @@ import { NUMBER_KINDS, type GlobalNumberFormat, type NumberFormat, - type NumberKind, - type NumberStyle + type NumberKind } from '../../types/NumberFormat'; import { DefaultPaddingSideSchema, @@ -25,17 +24,15 @@ 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_STYLES: (NumberStyle | undefined)[] = [undefined, 'compact', 'whole']; - // Cycle a number kind's global style: default (precise) -> compact -> whole -> default. // A global style forces that kind across all widgets (see resolveNumberFormat). function cycleGlobalNumberStyle(settings: Settings, kind: NumberKind): Settings { const current = settings.numberFormat?.[kind]?.style; - const currentIndex = NUMBER_FORMAT_STYLES.indexOf(current); - const nextStyle = NUMBER_FORMAT_STYLES[(currentIndex + 1) % NUMBER_FORMAT_STYLES.length]; + const nextStyle = getNextNumberStyle(current); const kindFormat: NumberFormat = { ...settings.numberFormat?.[kind] }; if (nextStyle === undefined) { diff --git a/src/utils/__tests__/number-format.test.ts b/src/utils/__tests__/number-format.test.ts index 5751d373..9ac3127a 100644 --- a/src/utils/__tests__/number-format.test.ts +++ b/src/utils/__tests__/number-format.test.ts @@ -16,6 +16,7 @@ import { cycleNumberStyle, formatCost, formatPercent, + getNextNumberStyle, getNumberFormatKeybind, renderMagnitude, resolveNumberFormat @@ -142,6 +143,17 @@ describe('cycleNumberStyle', () => { 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 }; diff --git a/src/utils/number-format.ts b/src/utils/number-format.ts index 92e1c504..e486be99 100644 --- a/src/utils/number-format.ts +++ b/src/utils/number-format.ts @@ -20,15 +20,20 @@ export function getNumberFormatKeybind(): CustomKeybind { return NUMBER_FORMAT_KEYBIND; } -// Cycle the number style: default (precise) -> compact -> whole -> default. -// Any explicit `decimals` is preserved across the cycle. -export function cycleNumberStyle(item: WidgetItem): WidgetItem { - const currentStyle = item.numberFormat?.style; - const nextStyle: NumberStyle | undefined = currentStyle === undefined +export function getNextNumberStyle(currentStyle: NumberStyle | undefined): NumberStyle | undefined { + const normalizedStyle = currentStyle === 'precise' ? undefined : currentStyle; + + return normalizedStyle === undefined ? 'compact' - : currentStyle === '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) { From 0fd6c3e9b56dbaca6cb5094fc414a9500f1f0cb9 Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Thu, 3 Sep 2026 12:53:16 -0400 Subject: [PATCH 5/9] fix: apply number styles to widget previews --- src/widgets/CacheHitRate.ts | 4 +-- src/widgets/CacheRead.ts | 7 +++-- src/widgets/CacheWrite.ts | 7 +++-- src/widgets/ContextBar.ts | 7 +++-- src/widgets/ContextLength.ts | 3 +- src/widgets/ContextWindow.ts | 3 +- src/widgets/FreeMemory.ts | 3 +- src/widgets/SessionCost.ts | 3 +- src/widgets/TokensCached.ts | 2 +- src/widgets/TokensInput.ts | 2 +- src/widgets/TokensOutput.ts | 2 +- src/widgets/TokensTotal.ts | 2 +- src/widgets/__tests__/CacheWidgets.test.ts | 17 +++++++++-- src/widgets/__tests__/ContextBar.test.ts | 22 ++++++++++++++ src/widgets/__tests__/FreeMemory.test.ts | 11 +++++++ src/widgets/__tests__/SessionCost.test.ts | 8 ++++++ src/widgets/__tests__/SpeedWidgets.test.ts | 7 +++++ src/widgets/__tests__/TokensWidgets.test.ts | 32 +++++++++++++++------ src/widgets/shared/speed-widget.tsx | 18 ++++++------ 19 files changed, 123 insertions(+), 37 deletions(-) diff --git a/src/widgets/CacheHitRate.ts b/src/widgets/CacheHitRate.ts index 28afd48d..c4543ec6 100644 --- a/src/widgets/CacheHitRate.ts +++ b/src/widgets/CacheHitRate.ts @@ -44,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); @@ -63,7 +64,6 @@ export class CacheHitRateWidget implements Widget { return null; } - const format = resolveNumberFormat('percent', item, settings); return formatRawOrLabeledValue(item, 'Cache Hit: ', formatPercent(hitRate, format)); } diff --git a/src/widgets/CacheRead.ts b/src/widgets/CacheRead.ts index 24e8e60c..0ac0a6c4 100644 --- a/src/widgets/CacheRead.ts +++ b/src/widgets/CacheRead.ts @@ -42,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); @@ -56,8 +59,6 @@ export class CacheReadWidget implements Widget { return null; } - const tokenFormat = resolveNumberFormat('token', item, settings); - const percentFormat = resolveNumberFormat('percent', item, settings); const value = formatTokensWithPercentage(tokens.read, getCacheReadPercentage(tokens), tokenFormat, percentFormat); return formatRawOrLabeledValue(item, 'Cache Read: ', value); } diff --git a/src/widgets/CacheWrite.ts b/src/widgets/CacheWrite.ts index 50896057..f85b9b9c 100644 --- a/src/widgets/CacheWrite.ts +++ b/src/widgets/CacheWrite.ts @@ -42,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); @@ -56,8 +59,6 @@ export class CacheWriteWidget implements Widget { return null; } - const tokenFormat = resolveNumberFormat('token', item, settings); - const percentFormat = resolveNumberFormat('percent', item, settings); const value = formatTokensWithPercentage(tokens.creation, getCacheWritePercentage(tokens), tokenFormat, percentFormat); return formatRawOrLabeledValue(item, 'Cache Write: ', value); } diff --git a/src/widgets/ContextBar.ts b/src/widgets/ContextBar.ts index 55122d16..16e42fce 100644 --- a/src/widgets/ContextBar.ts +++ b/src/widgets/ContextBar.ts @@ -87,13 +87,16 @@ export class ContextBarWidget implements Widget { 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}`; } diff --git a/src/widgets/ContextLength.ts b/src/widgets/ContextLength.ts index 5416b387..969cb5c6 100644 --- a/src/widgets/ContextLength.ts +++ b/src/widgets/ContextLength.ts @@ -21,7 +21,8 @@ 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); diff --git a/src/widgets/ContextWindow.ts b/src/widgets/ContextWindow.ts index 698b5d8e..f40ff2dc 100644 --- a/src/widgets/ContextWindow.ts +++ b/src/widgets/ContextWindow.ts @@ -25,7 +25,8 @@ 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); diff --git a/src/widgets/FreeMemory.ts b/src/widgets/FreeMemory.ts index b86c9396..6aa4485d 100644 --- a/src/widgets/FreeMemory.ts +++ b/src/widgets/FreeMemory.ts @@ -78,7 +78,8 @@ 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(); diff --git a/src/widgets/SessionCost.ts b/src/widgets/SessionCost.ts index b415d740..07e336f0 100644 --- a/src/widgets/SessionCost.ts +++ b/src/widgets/SessionCost.ts @@ -31,7 +31,8 @@ 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; diff --git a/src/widgets/TokensCached.ts b/src/widgets/TokensCached.ts index cfaf280b..237ac27b 100644 --- a/src/widgets/TokensCached.ts +++ b/src/widgets/TokensCached.ts @@ -30,7 +30,7 @@ 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) { diff --git a/src/widgets/TokensInput.ts b/src/widgets/TokensInput.ts index 44c9f4d4..1266deac 100644 --- a/src/widgets/TokensInput.ts +++ b/src/widgets/TokensInput.ts @@ -31,7 +31,7 @@ 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 diff --git a/src/widgets/TokensOutput.ts b/src/widgets/TokensOutput.ts index 545a47da..43dc46d4 100644 --- a/src/widgets/TokensOutput.ts +++ b/src/widgets/TokensOutput.ts @@ -31,7 +31,7 @@ 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 diff --git a/src/widgets/TokensTotal.ts b/src/widgets/TokensTotal.ts index bc7a9a86..a780ad31 100644 --- a/src/widgets/TokensTotal.ts +++ b/src/widgets/TokensTotal.ts @@ -30,7 +30,7 @@ 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) { diff --git a/src/widgets/__tests__/CacheWidgets.test.ts b/src/widgets/__tests__/CacheWidgets.test.ts index f2e42580..22bb5ffa 100644 --- a/src/widgets/__tests__/CacheWidgets.test.ts +++ b/src/widgets/__tests__/CacheWidgets.test.ts @@ -218,7 +218,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__/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 467af082..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']); }); diff --git a/src/widgets/__tests__/SpeedWidgets.test.ts b/src/widgets/__tests__/SpeedWidgets.test.ts index 562d3eaf..118820ea 100644 --- a/src/widgets/__tests__/SpeedWidgets.test.ts +++ b/src/widgets/__tests__/SpeedWidgets.test.ts @@ -69,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' } }); diff --git a/src/widgets/__tests__/TokensWidgets.test.ts b/src/widgets/__tests__/TokensWidgets.test.ts index 62504018..019f1a0c 100644 --- a/src/widgets/__tests__/TokensWidgets.test.ts +++ b/src/widgets/__tests__/TokensWidgets.test.ts @@ -209,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/shared/speed-widget.tsx b/src/widgets/shared/speed-widget.tsx index b858b92a..9373e3e4 100644 --- a/src/widgets/shared/speed-widget.tsx +++ b/src/widgets/shared/speed-widget.tsx @@ -46,8 +46,8 @@ interface SpeedWidgetKindConfig { label: string; displayName: string; description: string; - sessionPreview: string; - windowedPreview: string; + sessionPreview: number; + windowedPreview: number; } const SPEED_WIDGET_CONFIG: Record = { @@ -55,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 } }; @@ -124,7 +124,7 @@ export function renderSpeedWidgetValue( if (context.isPreview) { const previewValue = isWidgetSpeedWindowEnabled(item) ? config.windowedPreview : config.sessionPreview; - return formatRawOrLabeledValue(item, config.label, previewValue); + return formatRawOrLabeledValue(item, config.label, formatSpeed(previewValue, format)); } const metrics = getSpeedMetricsForWidget(item, context); From cf78868e615b2d442e07e6d7e88a25e5a62c43f9 Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Thu, 3 Sep 2026 12:54:29 -0400 Subject: [PATCH 6/9] fix: format zero-data percentage fallbacks --- src/widgets/BlockTimer.ts | 5 +++-- src/widgets/CacheHitRate.ts | 2 +- src/widgets/__tests__/BlockTimer.test.ts | 12 ++++++++++++ src/widgets/__tests__/CacheWidgets.test.ts | 1 + 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/widgets/BlockTimer.ts b/src/widgets/BlockTimer.ts index 915cb480..59beaace 100644 --- a/src/widgets/BlockTimer.ts +++ b/src/widgets/BlockTimer.ts @@ -99,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); } diff --git a/src/widgets/CacheHitRate.ts b/src/widgets/CacheHitRate.ts index c4543ec6..c7b7fc52 100644 --- a/src/widgets/CacheHitRate.ts +++ b/src/widgets/CacheHitRate.ts @@ -57,7 +57,7 @@ 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) { 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 22bb5ffa..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'); }); From c56d5d416078796da17121856e173ec8a276ab7e Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Thu, 3 Sep 2026 12:55:46 -0400 Subject: [PATCH 7/9] fix: format extra usage currency amounts --- src/widgets/ExtraUsageRemaining.ts | 9 ++++-- src/widgets/ExtraUsageUsed.ts | 9 ++++-- .../__tests__/ExtraUsageRemaining.test.ts | 26 ++++++++++++++++ src/widgets/__tests__/ExtraUsageUsed.test.ts | 26 ++++++++++++++++ src/widgets/__tests__/shared/currency.test.ts | 6 ++++ src/widgets/shared/currency.ts | 30 ++++++++++++------- 6 files changed, 89 insertions(+), 17 deletions(-) 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/__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__/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/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 }); } } From 8e9721fb75f120d1cdbafdedde8be954b5240725 Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Thu, 3 Sep 2026 13:11:07 -0400 Subject: [PATCH 8/9] fix: align global number format labels --- src/tui/components/GlobalOverridesMenu.tsx | 4 +- .../__tests__/GlobalOverridesMenu.test.ts | 44 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/tui/components/GlobalOverridesMenu.tsx b/src/tui/components/GlobalOverridesMenu.tsx index 1430f2c1..cd86a015 100644 --- a/src/tui/components/GlobalOverridesMenu.tsx +++ b/src/tui/components/GlobalOverridesMenu.tsx @@ -28,6 +28,8 @@ 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 { @@ -311,7 +313,7 @@ export const GlobalOverridesMenu: React.FC = ({ settin return ( {idx === numberFormatKindIndex ? '▶ ' : ' '} - {kind} + {kind.padStart(NUMBER_FORMAT_KIND_WIDTH)} {': '} {style} 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(); From 0961e159151d65ae0bcd7ed07dbef839c1572cef Mon Sep 17 00:00:00 2001 From: Matthew Breedlove Date: Thu, 3 Sep 2026 13:20:23 -0400 Subject: [PATCH 9/9] fix: show widget number style modifiers --- src/tui/components/ItemsEditor.tsx | 14 +- .../components/__tests__/ItemsEditor.test.ts | 166 ++++++++++++++++++ src/utils/__tests__/number-format.test.ts | 19 ++ src/utils/number-format.ts | 5 + 4 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 src/tui/components/__tests__/ItemsEditor.test.ts diff --git a/src/tui/components/ItemsEditor.tsx b/src/tui/components/ItemsEditor.tsx index 1e962062..470ee148 100644 --- a/src/tui/components/ItemsEditor.tsx +++ b/src/tui/components/ItemsEditor.tsx @@ -14,7 +14,10 @@ import type { } from '../../types/Widget'; import { getBackgroundColorsForPowerline } from '../../utils/colors'; import { generateGuid } from '../../utils/guid'; -import { getNumberFormatKeybind } from '../../utils/number-format'; +import { + getNumberFormatKeybind, + getNumberFormatModifierText +} from '../../utils/number-format'; import { canDetectTerminalWidth } from '../../utils/terminal'; import { filterWidgetCatalog, @@ -575,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 ( @@ -593,6 +599,12 @@ export const ItemsEditor: React.FC = ({ widgets, onUpdate, onB {modifierText} )} + {numberFormatModifierText && ( + + {' '} + {numberFormatModifierText} + + )} {hideModifierText && ( {' '} 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/utils/__tests__/number-format.test.ts b/src/utils/__tests__/number-format.test.ts index 9ac3127a..0430bd0c 100644 --- a/src/utils/__tests__/number-format.test.ts +++ b/src/utils/__tests__/number-format.test.ts @@ -18,6 +18,7 @@ import { formatPercent, getNextNumberStyle, getNumberFormatKeybind, + getNumberFormatModifierText, renderMagnitude, resolveNumberFormat } from '../number-format'; @@ -177,6 +178,24 @@ describe('getNumberFormatKeybind', () => { }); }); +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'); diff --git a/src/utils/number-format.ts b/src/utils/number-format.ts index e486be99..2041bb60 100644 --- a/src/utils/number-format.ts +++ b/src/utils/number-format.ts @@ -20,6 +20,11 @@ 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;