From 9749ae4a32256e419162dafd7dd56c916a17e4f4 Mon Sep 17 00:00:00 2001 From: simplybychris Date: Sun, 15 Feb 2026 18:36:10 +0100 Subject: [PATCH] feat: dynamic color thresholds for context percentage widgets Widget color changes based on context usage: yellow at warning, red at danger threshold. Configurable presets via 't' keybind in TUI (default 50/75%, conservative 30/60%, aggressive 70/90%). --- src/types/Widget.ts | 1 + src/utils/__tests__/color-thresholds.test.ts | 135 ++++++++++++++++ src/utils/color-thresholds.ts | 55 +++++++ src/utils/renderer.ts | 11 +- src/widgets/ContextPercentage.ts | 41 ++++- src/widgets/ContextPercentageUsable.ts | 42 ++++- .../__tests__/ContextPercentage.test.ts | 141 +++++++++++++++++ .../__tests__/ContextPercentageUsable.test.ts | 149 +++++++++++++++++- 8 files changed, 569 insertions(+), 6 deletions(-) create mode 100644 src/utils/__tests__/color-thresholds.test.ts create mode 100644 src/utils/color-thresholds.ts diff --git a/src/types/Widget.ts b/src/types/Widget.ts index 23ab461..6564e65 100644 --- a/src/types/Widget.ts +++ b/src/types/Widget.ts @@ -36,6 +36,7 @@ export interface Widget { getDisplayName(): string; getEditorDisplay(item: WidgetItem): WidgetEditorDisplay; render(item: WidgetItem, context: RenderContext, settings: Settings): string | null; + getEffectiveColor?(item: WidgetItem, context: RenderContext, settings: Settings): string | undefined; getCustomKeybinds?(): CustomKeybind[]; renderEditor?(props: WidgetEditorProps): React.ReactElement | null; supportsRawValue(): boolean; diff --git a/src/utils/__tests__/color-thresholds.test.ts b/src/utils/__tests__/color-thresholds.test.ts new file mode 100644 index 0000000..543b0ba --- /dev/null +++ b/src/utils/__tests__/color-thresholds.test.ts @@ -0,0 +1,135 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import type { WidgetItem } from '../../types/Widget'; +import { + THRESHOLD_CYCLE_ORDER, + getCurrentPreset, + getContextThresholdColor, + getPresetLabel +} from '../color-thresholds'; + +function makeItem(metadata?: Record): WidgetItem { + return { + id: 'test', + type: 'context-percentage', + metadata + }; +} + +describe('getContextThresholdColor', () => { + describe('default preset (50/75%)', () => { + it('should return undefined below warning threshold', () => { + expect(getContextThresholdColor(makeItem(), 0)).toBeUndefined(); + expect(getContextThresholdColor(makeItem(), 25)).toBeUndefined(); + expect(getContextThresholdColor(makeItem(), 49.9)).toBeUndefined(); + }); + + it('should return yellow at warning threshold', () => { + expect(getContextThresholdColor(makeItem(), 50)).toBe('yellow'); + }); + + it('should return yellow between warning and critical', () => { + expect(getContextThresholdColor(makeItem(), 60)).toBe('yellow'); + expect(getContextThresholdColor(makeItem(), 74.9)).toBe('yellow'); + }); + + it('should return red at critical threshold', () => { + expect(getContextThresholdColor(makeItem(), 75)).toBe('red'); + }); + + it('should return red above critical threshold', () => { + expect(getContextThresholdColor(makeItem(), 90)).toBe('red'); + expect(getContextThresholdColor(makeItem(), 100)).toBe('red'); + }); + }); + + describe('conservative preset (30/60%)', () => { + const item = makeItem({ thresholdPreset: 'conservative' }); + + it('should return undefined below 30%', () => { + expect(getContextThresholdColor(item, 29.9)).toBeUndefined(); + }); + + it('should return yellow at 30%', () => { + expect(getContextThresholdColor(item, 30)).toBe('yellow'); + }); + + it('should return red at 60%', () => { + expect(getContextThresholdColor(item, 60)).toBe('red'); + }); + }); + + describe('aggressive preset (70/90%)', () => { + const item = makeItem({ thresholdPreset: 'aggressive' }); + + it('should return undefined below 70%', () => { + expect(getContextThresholdColor(item, 69.9)).toBeUndefined(); + }); + + it('should return yellow at 70%', () => { + expect(getContextThresholdColor(item, 70)).toBe('yellow'); + }); + + it('should return red at 90%', () => { + expect(getContextThresholdColor(item, 90)).toBe('red'); + }); + }); + + describe('off preset', () => { + it('should return undefined regardless of percentage', () => { + const item = makeItem({ colorThresholds: 'false' }); + expect(getContextThresholdColor(item, 0)).toBeUndefined(); + expect(getContextThresholdColor(item, 50)).toBeUndefined(); + expect(getContextThresholdColor(item, 100)).toBeUndefined(); + }); + }); + + describe('edge cases', () => { + it('should handle 0% usage', () => { + expect(getContextThresholdColor(makeItem(), 0)).toBeUndefined(); + }); + + it('should handle 100% usage', () => { + expect(getContextThresholdColor(makeItem(), 100)).toBe('red'); + }); + + it('should handle no metadata', () => { + const item = makeItem(undefined); + expect(getContextThresholdColor(item, 60)).toBe('yellow'); + }); + }); +}); + +describe('getCurrentPreset', () => { + it('should return default when no metadata', () => { + expect(getCurrentPreset(makeItem())).toBe('default'); + }); + + it('should return the configured preset', () => { + expect(getCurrentPreset(makeItem({ thresholdPreset: 'conservative' }))).toBe('conservative'); + expect(getCurrentPreset(makeItem({ thresholdPreset: 'aggressive' }))).toBe('aggressive'); + }); + + it('should return off when colorThresholds is false', () => { + expect(getCurrentPreset(makeItem({ colorThresholds: 'false' }))).toBe('off'); + }); +}); + +describe('getPresetLabel', () => { + it('should return correct labels for all presets', () => { + expect(getPresetLabel('default')).toBe('thresholds: 50/75%'); + expect(getPresetLabel('conservative')).toBe('thresholds: 30/60%'); + expect(getPresetLabel('aggressive')).toBe('thresholds: 70/90%'); + expect(getPresetLabel('off')).toBe('thresholds: off'); + }); +}); + +describe('THRESHOLD_CYCLE_ORDER', () => { + it('should cycle through all four presets', () => { + expect(THRESHOLD_CYCLE_ORDER).toEqual(['default', 'conservative', 'aggressive', 'off']); + }); +}); diff --git a/src/utils/color-thresholds.ts b/src/utils/color-thresholds.ts new file mode 100644 index 0000000..f0a51df --- /dev/null +++ b/src/utils/color-thresholds.ts @@ -0,0 +1,55 @@ +import type { WidgetItem } from '../types/Widget'; + +export type ThresholdPreset = 'default' | 'conservative' | 'aggressive' | 'off'; + +export interface ThresholdConfig { + warn: number; + crit: number; + warnColor: string; + critColor: string; +} + +export const THRESHOLD_PRESETS: Record = { + default: { warn: 50, crit: 75, warnColor: 'yellow', critColor: 'red' }, + conservative: { warn: 30, crit: 60, warnColor: 'yellow', critColor: 'red' }, + aggressive: { warn: 70, crit: 90, warnColor: 'yellow', critColor: 'red' }, + off: null +}; + +export const THRESHOLD_CYCLE_ORDER: ThresholdPreset[] = ['default', 'conservative', 'aggressive', 'off']; + +/** + * Get the effective color for a context percentage widget based on usage thresholds. + * Uses the USAGE percentage (how full context is), regardless of inverse display mode. + */ +export function getContextThresholdColor(item: WidgetItem, usagePercentage: number): string | undefined { + if (item.metadata?.colorThresholds === 'false') return undefined; + + const preset = (item.metadata?.thresholdPreset as ThresholdPreset) ?? 'default'; + const config = THRESHOLD_PRESETS[preset]; + if (!config) return undefined; + + if (usagePercentage >= config.crit) return config.critColor; + if (usagePercentage >= config.warn) return config.warnColor; + return undefined; +} + +/** + * Get the current threshold preset from widget metadata. + */ +export function getCurrentPreset(item: WidgetItem): ThresholdPreset { + if (item.metadata?.colorThresholds === 'false') return 'off'; + return (item.metadata?.thresholdPreset as ThresholdPreset) ?? 'default'; +} + +/** + * Get display label for a threshold preset. + */ +export function getPresetLabel(preset: ThresholdPreset): string { + switch (preset) { + case 'default': return 'thresholds: 50/75%'; + case 'conservative': return 'thresholds: 30/60%'; + case 'aggressive': return 'thresholds: 70/90%'; + case 'off': return 'thresholds: off'; + } +} diff --git a/src/utils/renderer.ts b/src/utils/renderer.ts index c77ef59..b4f3386 100644 --- a/src/utils/renderer.ts +++ b/src/utils/renderer.ts @@ -174,8 +174,10 @@ function renderPowerlineStatusLine( const trailingPadding = omitTrailingPadding ? '' : padding; const paddedText = `${leadingPadding}${widgetText}${trailingPadding}`; - // Determine colors - let fgColor = widget.color ?? defaultColor; + // Determine colors - check for dynamic effective color (e.g., threshold-based) + const widgetImplForColor = getWidget(widget.type); + const effectiveColor = widgetImplForColor?.getEffectiveColor?.(widget, context, settings); + let fgColor = effectiveColor ?? widget.color ?? defaultColor; let bgColor = widget.backgroundColor; // Apply theme colors if a theme is set (and not 'custom') @@ -811,8 +813,11 @@ export function renderStatusLine( elements.push({ content: finalOutput, type: widget.type, widget }); } else { // Normal widget rendering with colors + // Check if widget has dynamic effective color (e.g., threshold-based) + const widgetImpl = getWidget(widget.type); + const effectiveColor = widgetImpl?.getEffectiveColor?.(widget, context, settings); elements.push({ - content: applyColorsWithOverride(widgetText, widget.color ?? defaultColor, widget.backgroundColor, widget.bold), + content: applyColorsWithOverride(widgetText, effectiveColor ?? widget.color ?? defaultColor, widget.backgroundColor, widget.bold), type: widget.type, widget }); diff --git a/src/widgets/ContextPercentage.ts b/src/widgets/ContextPercentage.ts index 7111dbb..a669aeb 100644 --- a/src/widgets/ContextPercentage.ts +++ b/src/widgets/ContextPercentage.ts @@ -6,6 +6,12 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { + THRESHOLD_CYCLE_ORDER, + getCurrentPreset, + getContextThresholdColor, + getPresetLabel +} from '../utils/color-thresholds'; import { getContextConfig } from '../utils/model-context'; export class ContextPercentageWidget implements Widget { @@ -20,6 +26,9 @@ export class ContextPercentageWidget implements Widget { modifiers.push('remaining'); } + const preset = getCurrentPreset(item); + modifiers.push(getPresetLabel(preset)); + return { displayText: this.getDisplayName(), modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined @@ -37,6 +46,23 @@ export class ContextPercentageWidget implements Widget { } }; } + if (action === 'cycle-thresholds') { + const current = getCurrentPreset(item); + const currentIndex = THRESHOLD_CYCLE_ORDER.indexOf(current); + const nextPreset = THRESHOLD_CYCLE_ORDER[(currentIndex + 1) % THRESHOLD_CYCLE_ORDER.length] ?? 'default'; + const { colorThresholds, thresholdPreset, ...restMetadata } = item.metadata ?? {}; + void colorThresholds; void thresholdPreset; + if (nextPreset === 'off') { + return { + ...item, + metadata: { ...restMetadata, colorThresholds: 'false' } + }; + } + return { + ...item, + metadata: { ...restMetadata, thresholdPreset: nextPreset } + }; + } return null; } @@ -57,9 +83,22 @@ export class ContextPercentageWidget implements Widget { return null; } + getEffectiveColor(item: WidgetItem, context: RenderContext, settings: Settings): string | undefined { + if (context.isPreview) return undefined; + if (!context.tokenMetrics) return undefined; + + const model = context.data?.model; + const modelId = typeof model === 'string' ? model : model?.id; + const contextConfig = getContextConfig(modelId); + const usedPercentage = Math.min(100, (context.tokenMetrics.contextLength / contextConfig.maxTokens) * 100); + + return getContextThresholdColor(item, usedPercentage); + } + getCustomKeybinds(): CustomKeybind[] { return [ - { key: 'l', label: '(l)eft/remaining', action: 'toggle-inverse' } + { key: 'l', label: '(l)eft/remaining', action: 'toggle-inverse' }, + { key: 't', label: '(t)hresholds', action: 'cycle-thresholds' } ]; } diff --git a/src/widgets/ContextPercentageUsable.ts b/src/widgets/ContextPercentageUsable.ts index 7ab5446..1f17cbc 100644 --- a/src/widgets/ContextPercentageUsable.ts +++ b/src/widgets/ContextPercentageUsable.ts @@ -6,6 +6,12 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { + THRESHOLD_CYCLE_ORDER, + getCurrentPreset, + getContextThresholdColor, + getPresetLabel +} from '../utils/color-thresholds'; import { getContextConfig } from '../utils/model-context'; export class ContextPercentageUsableWidget implements Widget { @@ -20,6 +26,9 @@ export class ContextPercentageUsableWidget implements Widget { modifiers.push('remaining'); } + const preset = getCurrentPreset(item); + modifiers.push(getPresetLabel(preset)); + return { displayText: this.getDisplayName(), modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined @@ -37,6 +46,23 @@ export class ContextPercentageUsableWidget implements Widget { } }; } + if (action === 'cycle-thresholds') { + const current = getCurrentPreset(item); + const currentIndex = THRESHOLD_CYCLE_ORDER.indexOf(current); + const nextPreset = THRESHOLD_CYCLE_ORDER[(currentIndex + 1) % THRESHOLD_CYCLE_ORDER.length] ?? 'default'; + const { colorThresholds, thresholdPreset, ...restMetadata } = item.metadata ?? {}; + void colorThresholds; void thresholdPreset; + if (nextPreset === 'off') { + return { + ...item, + metadata: { ...restMetadata, colorThresholds: 'false' } + }; + } + return { + ...item, + metadata: { ...restMetadata, thresholdPreset: nextPreset } + }; + } return null; } @@ -57,9 +83,23 @@ export class ContextPercentageUsableWidget implements Widget { return null; } + getEffectiveColor(item: WidgetItem, context: RenderContext, settings: Settings): string | undefined { + if (context.isPreview) return undefined; + if (!context.tokenMetrics) return undefined; + + const model = context.data?.model; + const modelId = typeof model === 'string' ? model : model?.id; + const contextConfig = getContextConfig(modelId); + // Use usableTokens for the usable variant + const usedPercentage = Math.min(100, (context.tokenMetrics.contextLength / contextConfig.usableTokens) * 100); + + return getContextThresholdColor(item, usedPercentage); + } + getCustomKeybinds(): CustomKeybind[] { return [ - { key: 'l', label: '(l)eft/remaining', action: 'toggle-inverse' } + { key: 'l', label: '(l)eft/remaining', action: 'toggle-inverse' }, + { key: 't', label: '(t)hresholds', action: 'cycle-thresholds' } ]; } diff --git a/src/widgets/__tests__/ContextPercentage.test.ts b/src/widgets/__tests__/ContextPercentage.test.ts index 111e672..036d2bd 100644 --- a/src/widgets/__tests__/ContextPercentage.test.ts +++ b/src/widgets/__tests__/ContextPercentage.test.ts @@ -33,6 +33,26 @@ function render(modelId: string | undefined, contextLength: number, rawValue = f return widget.render(item, context, DEFAULT_SETTINGS); } +function getEffectiveColor(contextLength: number, metadata?: Record) { + const widget = new ContextPercentageWidget(); + const context: RenderContext = { + data: { model: { id: 'claude-3-5-sonnet-20241022' } }, + tokenMetrics: { + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + totalTokens: 0, + contextLength + } + }; + const item: WidgetItem = { + id: 'context-percentage', + type: 'context-percentage', + metadata + }; + return widget.getEffectiveColor(item, context, DEFAULT_SETTINGS); +} + describe('ContextPercentageWidget', () => { describe('Sonnet 4.5 with 1M context window', () => { it('should calculate percentage using 1M denominator for Sonnet 4.5 with [1m] suffix', () => { @@ -62,4 +82,125 @@ describe('ContextPercentageWidget', () => { expect(result).toBe('Ctx: 21.0%'); }); }); + + describe('getEffectiveColor with thresholds', () => { + // 200k max tokens, so 100k = 50%, 150k = 75% + it('should return undefined below warning threshold', () => { + expect(getEffectiveColor(90000)).toBeUndefined(); // 45% + }); + + it('should return yellow at warning threshold (50%)', () => { + expect(getEffectiveColor(100000)).toBe('yellow'); // 50% + }); + + it('should return yellow between warning and critical', () => { + expect(getEffectiveColor(120000)).toBe('yellow'); // 60% + }); + + it('should return red at critical threshold (75%)', () => { + expect(getEffectiveColor(150000)).toBe('red'); // 75% + }); + + it('should return red above critical', () => { + expect(getEffectiveColor(180000)).toBe('red'); // 90% + }); + + it('should return undefined when thresholds are off', () => { + expect(getEffectiveColor(150000, { colorThresholds: 'false' })).toBeUndefined(); + }); + + it('should return undefined in preview mode', () => { + const widget = new ContextPercentageWidget(); + const context: RenderContext = { isPreview: true }; + const item: WidgetItem = { id: 'test', type: 'context-percentage' }; + expect(widget.getEffectiveColor(item, context, DEFAULT_SETTINGS)).toBeUndefined(); + }); + + it('should return undefined when no token metrics', () => { + const widget = new ContextPercentageWidget(); + const context: RenderContext = {}; + const item: WidgetItem = { id: 'test', type: 'context-percentage' }; + expect(widget.getEffectiveColor(item, context, DEFAULT_SETTINGS)).toBeUndefined(); + }); + + it('should use usage percentage regardless of inverse display mode', () => { + // Even with inverse mode, thresholds are based on usage (how full) + expect(getEffectiveColor(150000, { inverse: 'true' })).toBe('red'); + }); + }); + + describe('handleEditorAction for cycle-thresholds', () => { + const widget = new ContextPercentageWidget(); + + it('should cycle from default to conservative', () => { + const item: WidgetItem = { id: 'test', type: 'context-percentage' }; + const result = widget.handleEditorAction('cycle-thresholds', item); + expect(result?.metadata?.thresholdPreset).toBe('conservative'); + }); + + it('should cycle from conservative to aggressive', () => { + const item: WidgetItem = { id: 'test', type: 'context-percentage', metadata: { thresholdPreset: 'conservative' } }; + const result = widget.handleEditorAction('cycle-thresholds', item); + expect(result?.metadata?.thresholdPreset).toBe('aggressive'); + }); + + it('should cycle from aggressive to off', () => { + const item: WidgetItem = { id: 'test', type: 'context-percentage', metadata: { thresholdPreset: 'aggressive' } }; + const result = widget.handleEditorAction('cycle-thresholds', item); + expect(result?.metadata?.colorThresholds).toBe('false'); + expect(result?.metadata?.thresholdPreset).toBeUndefined(); + }); + + it('should cycle from off back to default', () => { + const item: WidgetItem = { id: 'test', type: 'context-percentage', metadata: { colorThresholds: 'false' } }; + const result = widget.handleEditorAction('cycle-thresholds', item); + expect(result?.metadata?.thresholdPreset).toBe('default'); + expect(result?.metadata?.colorThresholds).toBeUndefined(); + }); + + it('should preserve other metadata when cycling', () => { + const item: WidgetItem = { id: 'test', type: 'context-percentage', metadata: { inverse: 'true' } }; + const result = widget.handleEditorAction('cycle-thresholds', item); + expect(result?.metadata?.inverse).toBe('true'); + expect(result?.metadata?.thresholdPreset).toBe('conservative'); + }); + }); + + describe('getEditorDisplay with thresholds', () => { + const widget = new ContextPercentageWidget(); + + it('should show default threshold label', () => { + const item: WidgetItem = { id: 'test', type: 'context-percentage' }; + const display = widget.getEditorDisplay(item); + expect(display.modifierText).toBe('(thresholds: 50/75%)'); + }); + + it('should show conservative threshold label', () => { + const item: WidgetItem = { id: 'test', type: 'context-percentage', metadata: { thresholdPreset: 'conservative' } }; + const display = widget.getEditorDisplay(item); + expect(display.modifierText).toBe('(thresholds: 30/60%)'); + }); + + it('should show off label', () => { + const item: WidgetItem = { id: 'test', type: 'context-percentage', metadata: { colorThresholds: 'false' } }; + const display = widget.getEditorDisplay(item); + expect(display.modifierText).toBe('(thresholds: off)'); + }); + + it('should show both remaining and threshold label', () => { + const item: WidgetItem = { id: 'test', type: 'context-percentage', metadata: { inverse: 'true' } }; + const display = widget.getEditorDisplay(item); + expect(display.modifierText).toBe('(remaining, thresholds: 50/75%)'); + }); + }); + + describe('getCustomKeybinds', () => { + it('should include threshold keybind', () => { + const widget = new ContextPercentageWidget(); + const keybinds = widget.getCustomKeybinds(); + const thresholdKeybind = keybinds.find(kb => kb.action === 'cycle-thresholds'); + expect(thresholdKeybind).toBeDefined(); + expect(thresholdKeybind?.key).toBe('t'); + }); + }); }); \ No newline at end of file diff --git a/src/widgets/__tests__/ContextPercentageUsable.test.ts b/src/widgets/__tests__/ContextPercentageUsable.test.ts index 60595a0..d1d6a0a 100644 --- a/src/widgets/__tests__/ContextPercentageUsable.test.ts +++ b/src/widgets/__tests__/ContextPercentageUsable.test.ts @@ -33,6 +33,26 @@ function render(modelId: string | undefined, contextLength: number, rawValue = f return widget.render(item, context, DEFAULT_SETTINGS); } +function getEffectiveColor(contextLength: number, metadata?: Record) { + const widget = new ContextPercentageUsableWidget(); + const context: RenderContext = { + data: { model: { id: 'claude-3-5-sonnet-20241022' } }, + tokenMetrics: { + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + totalTokens: 0, + contextLength + } + }; + const item: WidgetItem = { + id: 'context-percentage-usable', + type: 'context-percentage-usable', + metadata + }; + return widget.getEffectiveColor(item, context, DEFAULT_SETTINGS); +} + describe('ContextPercentageUsableWidget', () => { describe('Sonnet 4.5 with 800k usable tokens', () => { it('should calculate percentage using 800k denominator for Sonnet 4.5 with [1m] suffix', () => { @@ -62,4 +82,131 @@ describe('ContextPercentageUsableWidget', () => { expect(result).toBe('Ctx(u): 26.3%'); }); }); -}); \ No newline at end of file + + describe('getEffectiveColor with thresholds', () => { + // 160k usable tokens, so 80k = 50%, 120k = 75% + it('should return undefined below warning threshold', () => { + expect(getEffectiveColor(72000)).toBeUndefined(); // 45% + }); + + it('should return yellow at warning threshold (50%)', () => { + expect(getEffectiveColor(80000)).toBe('yellow'); // 50% + }); + + it('should return yellow between warning and critical', () => { + expect(getEffectiveColor(100000)).toBe('yellow'); // 62.5% + }); + + it('should return red at critical threshold (75%)', () => { + expect(getEffectiveColor(120000)).toBe('red'); // 75% + }); + + it('should return red above critical', () => { + expect(getEffectiveColor(150000)).toBe('red'); // 93.75% + }); + + it('should return undefined when thresholds are off', () => { + expect(getEffectiveColor(120000, { colorThresholds: 'false' })).toBeUndefined(); + }); + + it('should return undefined in preview mode', () => { + const widget = new ContextPercentageUsableWidget(); + const context: RenderContext = { isPreview: true }; + const item: WidgetItem = { id: 'test', type: 'context-percentage-usable' }; + expect(widget.getEffectiveColor(item, context, DEFAULT_SETTINGS)).toBeUndefined(); + }); + + it('should return undefined when no token metrics', () => { + const widget = new ContextPercentageUsableWidget(); + const context: RenderContext = {}; + const item: WidgetItem = { id: 'test', type: 'context-percentage-usable' }; + expect(widget.getEffectiveColor(item, context, DEFAULT_SETTINGS)).toBeUndefined(); + }); + + it('should use usableTokens (160k) not maxTokens (200k) for percentage', () => { + // 80k / 160k = 50% (yellow with usable), but 80k / 200k = 40% (would be undefined with max) + expect(getEffectiveColor(80000)).toBe('yellow'); + // 90k / 160k = 56.25% (yellow), but 90k / 200k = 45% (would be undefined with max) + expect(getEffectiveColor(90000)).toBe('yellow'); + }); + + it('should use usage percentage regardless of inverse display mode', () => { + expect(getEffectiveColor(120000, { inverse: 'true' })).toBe('red'); + }); + }); + + describe('handleEditorAction for cycle-thresholds', () => { + const widget = new ContextPercentageUsableWidget(); + + it('should cycle from default to conservative', () => { + const item: WidgetItem = { id: 'test', type: 'context-percentage-usable' }; + const result = widget.handleEditorAction('cycle-thresholds', item); + expect(result?.metadata?.thresholdPreset).toBe('conservative'); + }); + + it('should cycle from conservative to aggressive', () => { + const item: WidgetItem = { id: 'test', type: 'context-percentage-usable', metadata: { thresholdPreset: 'conservative' } }; + const result = widget.handleEditorAction('cycle-thresholds', item); + expect(result?.metadata?.thresholdPreset).toBe('aggressive'); + }); + + it('should cycle from aggressive to off', () => { + const item: WidgetItem = { id: 'test', type: 'context-percentage-usable', metadata: { thresholdPreset: 'aggressive' } }; + const result = widget.handleEditorAction('cycle-thresholds', item); + expect(result?.metadata?.colorThresholds).toBe('false'); + expect(result?.metadata?.thresholdPreset).toBeUndefined(); + }); + + it('should cycle from off back to default', () => { + const item: WidgetItem = { id: 'test', type: 'context-percentage-usable', metadata: { colorThresholds: 'false' } }; + const result = widget.handleEditorAction('cycle-thresholds', item); + expect(result?.metadata?.thresholdPreset).toBe('default'); + expect(result?.metadata?.colorThresholds).toBeUndefined(); + }); + + it('should preserve other metadata when cycling', () => { + const item: WidgetItem = { id: 'test', type: 'context-percentage-usable', metadata: { inverse: 'true' } }; + const result = widget.handleEditorAction('cycle-thresholds', item); + expect(result?.metadata?.inverse).toBe('true'); + expect(result?.metadata?.thresholdPreset).toBe('conservative'); + }); + }); + + describe('getEditorDisplay with thresholds', () => { + const widget = new ContextPercentageUsableWidget(); + + it('should show default threshold label', () => { + const item: WidgetItem = { id: 'test', type: 'context-percentage-usable' }; + const display = widget.getEditorDisplay(item); + expect(display.modifierText).toBe('(thresholds: 50/75%)'); + }); + + it('should show conservative threshold label', () => { + const item: WidgetItem = { id: 'test', type: 'context-percentage-usable', metadata: { thresholdPreset: 'conservative' } }; + const display = widget.getEditorDisplay(item); + expect(display.modifierText).toBe('(thresholds: 30/60%)'); + }); + + it('should show off label', () => { + const item: WidgetItem = { id: 'test', type: 'context-percentage-usable', metadata: { colorThresholds: 'false' } }; + const display = widget.getEditorDisplay(item); + expect(display.modifierText).toBe('(thresholds: off)'); + }); + + it('should show both remaining and threshold label', () => { + const item: WidgetItem = { id: 'test', type: 'context-percentage-usable', metadata: { inverse: 'true' } }; + const display = widget.getEditorDisplay(item); + expect(display.modifierText).toBe('(remaining, thresholds: 50/75%)'); + }); + }); + + describe('getCustomKeybinds', () => { + it('should include threshold keybind', () => { + const widget = new ContextPercentageUsableWidget(); + const keybinds = widget.getCustomKeybinds(); + const thresholdKeybind = keybinds.find(kb => kb.action === 'cycle-thresholds'); + expect(thresholdKeybind).toBeDefined(); + expect(thresholdKeybind?.key).toBe('t'); + }); + }); +});