diff --git a/src/widgets/CompactionCounter.ts b/src/widgets/CompactionCounter.ts index 5b6467be..bc3fa32f 100644 --- a/src/widgets/CompactionCounter.ts +++ b/src/widgets/CompactionCounter.ts @@ -39,6 +39,15 @@ const TOGGLE_TRIGGERS_ACTION = 'toggle-triggers'; const SHOW_TRIGGERS_METADATA_KEY = 'showTriggers'; const TOGGLE_RECLAIMED_ACTION = 'toggle-reclaimed'; const SHOW_RECLAIMED_METADATA_KEY = 'showReclaimed'; +// Selectable metric. The default 'count' keeps the full composite display +// (icon, count, optional trigger split, optional reclaimed). The other metrics +// render just that one value as a raw number, so several instances can be +// composed with custom separators/symbols into a layout like "2 · 1a 1m · ↓2M". +const METRICS = ['count', 'auto', 'manual', 'unknown', 'reclaimed'] as const; +type CompactionMetric = typeof METRICS[number]; +const DEFAULT_METRIC: CompactionMetric = 'count'; +const METRIC_METADATA_KEY = 'metric'; +const CYCLE_METRIC_ACTION = 'cycle-metric'; const RECLAIMED_SLOT: SymbolSlot = { id: 'symbolReclaimed', label: 'Reclaimed', defaultSymbol: '↓' }; const SAMPLE_STATS: CompactionData = Object.freeze({ count: 2, @@ -102,6 +111,41 @@ function toggleHideZero(item: WidgetItem): WidgetItem { }; } +function getMetric(item: WidgetItem): CompactionMetric { + const metric = item.metadata?.[METRIC_METADATA_KEY]; + return (METRICS as readonly string[]).includes(metric ?? '') ? (metric as CompactionMetric) : DEFAULT_METRIC; +} + +function setMetric(item: WidgetItem, metric: CompactionMetric): WidgetItem { + if (metric === DEFAULT_METRIC) { + const { [METRIC_METADATA_KEY]: removedMetric, ...restMetadata } = item.metadata ?? {}; + void removedMetric; + + return { + ...item, + metadata: Object.keys(restMetadata).length > 0 ? restMetadata : undefined + }; + } + + return { + ...item, + metadata: { + ...(item.metadata ?? {}), + [METRIC_METADATA_KEY]: metric + } + }; +} + +function getMetricValue(data: CompactionData, metric: CompactionMetric): number { + switch (metric) { + case 'count': return data.count; + case 'auto': return data.byTrigger.auto; + case 'manual': return data.byTrigger.manual; + case 'unknown': return data.byTrigger.unknown; + case 'reclaimed': return data.tokensReclaimed; + } +} + function formatReclaimedSuffix(tokensReclaimed: number, item: WidgetItem): string { if (tokensReclaimed <= 0) { return ''; @@ -169,7 +213,9 @@ function formatCount(count: number, format: CompactionCounterFormat, icon: strin * compaction has occurred by counting compact_boundary markers in the transcript. * * Shows ↻ N by default, including ↻ 0 before compaction occurs. Can be - * configured to hide when count is 0. + * configured to hide when count is 0. A `metric` selector switches it to emit a + * single raw value (count, auto, manual, unknown, or reclaimed) so several + * instances can be composed into a custom layout. */ export class CompactionCounterWidget implements Widget { getDefaultColor(): string { return 'yellow'; } @@ -177,15 +223,22 @@ export class CompactionCounterWidget implements Widget { getDisplayName(): string { return 'Compaction Counter'; } getCategory(): string { return 'Context'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { - const modifiers: string[] = [getFormat(item)]; - if (isNerdFontEnabled(item)) { - modifiers.push('nerd font'); - } - if (isMetadataFlagEnabled(item, SHOW_TRIGGERS_METADATA_KEY)) { - modifiers.push('trigger split'); - } - if (isMetadataFlagEnabled(item, SHOW_RECLAIMED_METADATA_KEY)) { - modifiers.push('reclaimed'); + const metric = getMetric(item); + const modifiers: string[] = []; + + if (metric !== DEFAULT_METRIC) { + modifiers.push(`${metric} value`); + } else { + modifiers.push(getFormat(item)); + if (isNerdFontEnabled(item)) { + modifiers.push('nerd font'); + } + if (isMetadataFlagEnabled(item, SHOW_TRIGGERS_METADATA_KEY)) { + modifiers.push('trigger split'); + } + if (isMetadataFlagEnabled(item, SHOW_RECLAIMED_METADATA_KEY)) { + modifiers.push('reclaimed'); + } } if (isHideZeroEnabled(item)) { modifiers.push('hide zero'); @@ -198,6 +251,13 @@ export class CompactionCounterWidget implements Widget { } handleEditorAction(action: string, item: WidgetItem): WidgetItem | null { + if (action === CYCLE_METRIC_ACTION) { + const currentMetric = getMetric(item); + const nextMetric = METRICS[(METRICS.indexOf(currentMetric) + 1) % METRICS.length] ?? DEFAULT_METRIC; + + return setMetric(item, nextMetric); + } + if (action === CYCLE_FORMAT_ACTION) { const currentFormat = getFormat(item); const nextFormat = FORMATS[(FORMATS.indexOf(currentFormat) + 1) % FORMATS.length] ?? DEFAULT_FORMAT; @@ -226,28 +286,41 @@ export class CompactionCounterWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { void settings; - const icon = isNerdFontEnabled(item) ? COMPACTION_NERD_FONT_ICON : COMPACTION_ICON; + const data = context.isPreview ? SAMPLE_STATS : (context.compactionData ?? ZERO_COMPACTION_STATS); + const metric = getMetric(item); - if (context.isPreview) { - return formatStats(SAMPLE_STATS, item, icon); + if (metric !== DEFAULT_METRIC) { + const value = getMetricValue(data, metric); + if (value === 0 && isHideZeroEnabled(item) && !context.isPreview) { + return null; + } + return metric === 'reclaimed' ? formatTokens(value) : String(value); } - const data = context.compactionData ?? ZERO_COMPACTION_STATS; - if (data.count === 0 && isHideZeroEnabled(item)) + if (data.count === 0 && isHideZeroEnabled(item) && !context.isPreview) { return null; + } + const icon = isNerdFontEnabled(item) ? COMPACTION_NERD_FONT_ICON : COMPACTION_ICON; return formatStats(data, item, icon); } getCustomKeybinds(item?: WidgetItem): CustomKeybind[] { const keybinds: CustomKeybind[] = [ - { key: 'f', label: '(f)ormat', action: CYCLE_FORMAT_ACTION } + { key: 'm', label: '(m)etric', action: CYCLE_METRIC_ACTION } ]; + // The format / glyph / trigger toggles only shape the composite 'count' + // display; a single-metric value just needs the metric and hide-zero. + if (item !== undefined && getMetric(item) !== DEFAULT_METRIC) { + keybinds.push({ key: 'h', label: '(h)ide when zero', action: TOGGLE_HIDE_ZERO_ACTION }); + return keybinds; + } + + keybinds.push({ key: 'f', label: '(f)ormat', action: CYCLE_FORMAT_ACTION }); if (item === undefined || getFormat(item) === DEFAULT_FORMAT) { keybinds.push({ key: 'n', label: '(n)erd font', action: TOGGLE_NERD_FONT_ACTION }); } - keybinds.push({ key: 's', label: '(s)plit by trigger', action: TOGGLE_TRIGGERS_ACTION }); keybinds.push({ key: 't', label: '(t)okens reclaimed', action: TOGGLE_RECLAIMED_ACTION }); keybinds.push({ key: 'h', label: '(h)ide when zero', action: TOGGLE_HIDE_ZERO_ACTION }); diff --git a/src/widgets/__tests__/CompactionCounter.test.ts b/src/widgets/__tests__/CompactionCounter.test.ts index e3f9cef3..0e98bbb0 100644 --- a/src/widgets/__tests__/CompactionCounter.test.ts +++ b/src/widgets/__tests__/CompactionCounter.test.ts @@ -252,8 +252,9 @@ describe('CompactionCounterWidget', () => { }); describe('editor', () => { - it('uses f and n as keybinds for the default format', () => { + it('uses metric, format, and toggle keybinds in count mode', () => { expect(new CompactionCounterWidget().getCustomKeybinds(ITEM)).toEqual([ + { key: 'm', label: '(m)etric', action: 'cycle-metric' }, { key: 'f', label: '(f)ormat', action: 'cycle-format' }, { key: 'n', label: '(n)erd font', action: 'toggle-nerd-font' }, { key: 's', label: '(s)plit by trigger', action: 'toggle-triggers' }, @@ -268,6 +269,7 @@ describe('CompactionCounterWidget', () => { ...ITEM, metadata: { format: 'text-and-number' } })).toEqual([ + { key: 'm', label: '(m)etric', action: 'cycle-metric' }, { key: 'f', label: '(f)ormat', action: 'cycle-format' }, { key: 's', label: '(s)plit by trigger', action: 'toggle-triggers' }, { key: 't', label: '(t)okens reclaimed', action: 'toggle-reclaimed' }, @@ -410,4 +412,101 @@ describe('CompactionCounterWidget', () => { }); }); }); + + describe('metric selector', () => { + it('renders only the auto-trigger count when metric is auto', () => { + expect(render({ + compactionData: { count: 5, byTrigger: { auto: 3, manual: 2, unknown: 0 } }, + item: { ...ITEM, metadata: { metric: 'auto' } } + })).toBe('3'); + }); + + it('renders only the manual-trigger count when metric is manual', () => { + expect(render({ + compactionData: { count: 5, byTrigger: { auto: 3, manual: 2, unknown: 0 } }, + item: { ...ITEM, metadata: { metric: 'manual' } } + })).toBe('2'); + }); + + it('renders only the unknown-trigger count when metric is unknown', () => { + expect(render({ + compactionData: { count: 5, byTrigger: { auto: 3, manual: 1, unknown: 1 } }, + item: { ...ITEM, metadata: { metric: 'unknown' } } + })).toBe('1'); + }); + + it('renders the reclaimed tokens formatted when metric is reclaimed', () => { + expect(render({ + compactionData: { count: 2, tokensReclaimed: 887000 }, + item: { ...ITEM, metadata: { metric: 'reclaimed' } } + })).toBe('887.0k'); + }); + + it('emits a raw value, ignoring format and icon settings', () => { + expect(render({ + compactionData: { count: 5, byTrigger: { auto: 3, manual: 2, unknown: 0 } }, + item: { ...ITEM, metadata: { metric: 'auto', format: 'icon-space-number', nerdFont: 'true' } } + })).toBe('3'); + }); + + it('hides a zero metric value when hide zero is enabled', () => { + expect(render({ + compactionData: { count: 4, byTrigger: { auto: 0, manual: 4, unknown: 0 } }, + item: { ...ITEM, metadata: { metric: 'auto', hideZero: 'true' } } + })).toBeNull(); + }); + + it('still shows a zero metric value when hide zero is off', () => { + expect(render({ + compactionData: { count: 4, byTrigger: { auto: 0, manual: 4, unknown: 0 } }, + item: { ...ITEM, metadata: { metric: 'auto' } } + })).toBe('0'); + }); + + it('shows the sample metric value in preview mode, ignoring hide zero', () => { + expect(render({ + isPreview: true, + item: { ...ITEM, metadata: { metric: 'unknown', hideZero: 'true' } } + })).toBe('0'); + expect(render({ + isPreview: true, + item: { ...ITEM, metadata: { metric: 'reclaimed' } } + })).toBe('120.0k'); + }); + + it('shows the metric in the editor display', () => { + expect(new CompactionCounterWidget().getEditorDisplay({ + ...ITEM, + metadata: { metric: 'reclaimed', hideZero: 'true' } + })).toEqual({ + displayText: 'Compaction Counter', + modifierText: '(reclaimed value, hide zero)' + }); + }); + + it('uses only metric and hide-zero keybinds in metric mode', () => { + expect(new CompactionCounterWidget().getCustomKeybinds({ + ...ITEM, + metadata: { metric: 'auto' } + })).toEqual([ + { key: 'm', label: '(m)etric', action: 'cycle-metric' }, + { key: 'h', label: '(h)ide when zero', action: 'toggle-hide-zero' } + ]); + }); + + it('cycles count -> auto -> manual -> unknown -> reclaimed -> count', () => { + const widget = new CompactionCounterWidget(); + const auto = widget.handleEditorAction('cycle-metric', ITEM); + const manual = widget.handleEditorAction('cycle-metric', auto ?? ITEM); + const unknown = widget.handleEditorAction('cycle-metric', manual ?? ITEM); + const reclaimed = widget.handleEditorAction('cycle-metric', unknown ?? ITEM); + const count = widget.handleEditorAction('cycle-metric', reclaimed ?? ITEM); + + expect(auto?.metadata?.metric).toBe('auto'); + expect(manual?.metadata?.metric).toBe('manual'); + expect(unknown?.metadata?.metric).toBe('unknown'); + expect(reclaimed?.metadata?.metric).toBe('reclaimed'); + expect(count?.metadata?.metric).toBeUndefined(); + }); + }); });