diff --git a/src/types/RenderContext.ts b/src/types/RenderContext.ts index 9e088c9b..0f2b5962 100644 --- a/src/types/RenderContext.ts +++ b/src/types/RenderContext.ts @@ -6,6 +6,10 @@ import type { TokenMetrics } from './TokenMetrics'; export interface RenderContext { data?: StatusJSON; tokenMetrics?: TokenMetrics | null; + contextWindow?: { + totalInputTokens: number; + contextWindowSize: number; + } | null; sessionDuration?: string | null; blockMetrics?: BlockMetrics | null; terminalWidth?: number | null; diff --git a/src/types/Settings.ts b/src/types/Settings.ts index ebde1fd7..a39d917f 100644 --- a/src/types/Settings.ts +++ b/src/types/Settings.ts @@ -22,6 +22,18 @@ export const SettingsSchema_v1 = z.object({ globalBold: z.boolean().optional() }); +const HeatGaugeThresholdSetSchema = z.object({ + cool: z.number().min(0).max(100), + warm: z.number().min(0).max(100), + hot: z.number().min(0).max(100), + veryHot: z.number().min(0).max(100) +}).refine( + t => t.cool < t.warm && t.warm < t.hot && t.hot < t.veryHot, + { message: 'Thresholds must be strictly ascending: cool < warm < hot < veryHot' } +); + +export type HeatGaugeThresholdSet = z.infer; + // Main settings schema with defaults export const SettingsSchema = z.object({ version: z.number().default(CURRENT_VERSION), @@ -61,7 +73,8 @@ export const SettingsSchema = z.object({ updatemessage: z.object({ message: z.string().nullable().optional(), remaining: z.number().nullable().optional() - }).optional() + }).optional(), + heatGaugeThresholds: HeatGaugeThresholdSetSchema.optional() }); // Inferred type from schema diff --git a/src/types/Widget.ts b/src/types/Widget.ts index 23ab4612..a6abafe2 100644 --- a/src/types/Widget.ts +++ b/src/types/Widget.ts @@ -18,6 +18,7 @@ export const WidgetItemSchema = z.object({ preserveColors: z.boolean().optional(), timeout: z.number().optional(), merge: z.union([z.boolean(), z.literal('no-padding')]).optional(), + heatGaugeColors: z.boolean().optional(), metadata: z.record(z.string(), z.string()).optional() }); diff --git a/src/types/__tests__/Settings.test.ts b/src/types/__tests__/Settings.test.ts new file mode 100644 index 00000000..55f705c6 --- /dev/null +++ b/src/types/__tests__/Settings.test.ts @@ -0,0 +1,49 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import { SettingsSchema } from '../Settings'; + +describe('SettingsSchema heatGaugeThresholds', () => { + it('should accept settings without heatGaugeThresholds', () => { + const result = SettingsSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it('should accept valid heatGaugeThresholds', () => { + const result = SettingsSchema.safeParse({ heatGaugeThresholds: { cool: 20, warm: 35, hot: 50, veryHot: 65 } }); + expect(result.success).toBe(true); + }); + + it('should reject thresholds where cool >= warm', () => { + const result = SettingsSchema.safeParse({ heatGaugeThresholds: { cool: 40, warm: 35, hot: 50, veryHot: 65 } }); + expect(result.success).toBe(false); + }); + + it('should reject thresholds where warm >= hot', () => { + const result = SettingsSchema.safeParse({ heatGaugeThresholds: { cool: 20, warm: 50, hot: 50, veryHot: 65 } }); + expect(result.success).toBe(false); + }); + + it('should reject thresholds where hot >= veryHot', () => { + const result = SettingsSchema.safeParse({ heatGaugeThresholds: { cool: 20, warm: 35, hot: 70, veryHot: 65 } }); + expect(result.success).toBe(false); + }); + + it('should reject thresholds below 0', () => { + const result = SettingsSchema.safeParse({ heatGaugeThresholds: { cool: -5, warm: 35, hot: 50, veryHot: 65 } }); + expect(result.success).toBe(false); + }); + + it('should reject thresholds above 100', () => { + const result = SettingsSchema.safeParse({ heatGaugeThresholds: { cool: 20, warm: 35, hot: 50, veryHot: 105 } }); + expect(result.success).toBe(false); + }); + + it('should reject partial threshold objects (missing fields)', () => { + const result = SettingsSchema.safeParse({ heatGaugeThresholds: { cool: 20, warm: 35 } }); + expect(result.success).toBe(false); + }); +}); \ No newline at end of file diff --git a/src/utils/__tests__/colors.test.ts b/src/utils/__tests__/colors.test.ts new file mode 100644 index 00000000..bf42b84d --- /dev/null +++ b/src/utils/__tests__/colors.test.ts @@ -0,0 +1,76 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import { getHeatGaugeColor } from '../colors'; + +describe('getHeatGaugeColor', () => { + describe('Default thresholds', () => { + it('should return cool cyan for low usage (< 30%)', () => { + const color = getHeatGaugeColor(10); + expect(color).toBe('hex:00D9FF'); // Cyan - cool + }); + + it('should return green for comfortable range (30-40%)', () => { + const color = getHeatGaugeColor(35); + expect(color).toBe('hex:4ADE80'); // Green - comfortable + }); + + it('should return yellow at pretty hot threshold (40%)', () => { + const color = getHeatGaugeColor(40); + expect(color).toBe('hex:FDE047'); // Yellow - pretty hot + }); + + it('should return yellow in pretty hot range (40-55%)', () => { + const color = getHeatGaugeColor(50); + expect(color).toBe('hex:FDE047'); // Yellow - pretty hot + }); + + it('should return orange at very hot threshold (55%)', () => { + const color = getHeatGaugeColor(55); + expect(color).toBe('hex:FB923C'); // Orange - very hot + }); + + it('should return orange in very hot range (55-70%)', () => { + const color = getHeatGaugeColor(65); + expect(color).toBe('hex:FB923C'); // Orange - very hot + }); + + it('should return red for high usage (70%+)', () => { + const color = getHeatGaugeColor(70); + expect(color).toBe('hex:F87171'); // Red - critical + }); + }); + + describe('Custom thresholds', () => { + it('should use custom thresholds when provided', () => { + // With custom cool=20, 25% should be green (above cool) + // Default would return cyan (25 < default cool=30) + const color = getHeatGaugeColor(25, { cool: 20, warm: 35, hot: 50, veryHot: 65 }); + expect(color).toBe('hex:4ADE80'); // Green + }); + + it('should fall back to defaults when no custom thresholds provided', () => { + // 25% with defaults = cyan (< 30%) + const color = getHeatGaugeColor(25); + expect(color).toBe('hex:00D9FF'); // Cyan + }); + + it('should apply custom hot threshold correctly', () => { + const color = getHeatGaugeColor(45, { cool: 20, warm: 35, hot: 50, veryHot: 65 }); + expect(color).toBe('hex:FDE047'); // Yellow — 45 is between warm=35 and hot=50 + }); + + it('should apply custom veryHot threshold correctly', () => { + const color = getHeatGaugeColor(60, { cool: 20, warm: 35, hot: 50, veryHot: 65 }); + expect(color).toBe('hex:FB923C'); // Orange — 60 is between hot=50 and veryHot=65 + }); + + it('should apply custom critical threshold correctly', () => { + const color = getHeatGaugeColor(70, { cool: 20, warm: 35, hot: 50, veryHot: 65 }); + expect(color).toBe('hex:F87171'); // Red — 70 >= veryHot=65 + }); + }); +}); \ No newline at end of file diff --git a/src/utils/colors.ts b/src/utils/colors.ts index 7fc549cb..1e9fdf7d 100644 --- a/src/utils/colors.ts +++ b/src/utils/colors.ts @@ -1,6 +1,7 @@ import chalk, { type ChalkInstance } from 'chalk'; import type { ColorEntry } from '../types/ColorEntry'; +import type { HeatGaugeThresholdSet } from '../types/Settings'; // Re-export for backward compatibility export type { ColorEntry }; @@ -82,6 +83,58 @@ export function bgToFg(colorName: string | undefined): string | undefined { return colorName; } +/** + * Maps a percentage value (0-100) to a heat gauge color. + * + * Default thresholds: cool=30, warm=40, hot=55, veryHot=70 + * - < 30%: Cool cyan - plenty of space + * - 30-40%: Comfortable green + * - 40-55%: "Pretty hot" yellow - getting warm + * - 55-70%: "Very hot" orange - concerning + * - 70%+: Critical red - take action + * + * **Color Selection**: All colors are from the Tailwind CSS palette and tested + * for visibility in both light and dark terminal themes. + * + * **Usage**: Called by ContextPercentage widgets to provide visual feedback. + * Heat gauge colors override widget-level color settings to ensure consistent + * visual warnings about context usage. + * + * @param percentage - The percentage value (0-100) to colorize + * @param customThresholds - Optional custom thresholds that override defaults + * @returns A color name string in hex:XXXXXX format, compatible with getChalkColor + * + * @example + * // 45% usage - shows yellow (pretty hot) + * const color = getHeatGaugeColor(45); // Returns 'hex:FDE047' + * + * @example + * // Custom thresholds + * const color = getHeatGaugeColor(25, { cool: 20, warm: 35, hot: 50, veryHot: 65 }); // Returns 'hex:4ADE80' + */ +export function getHeatGaugeColor(percentage: number, customThresholds?: HeatGaugeThresholdSet): string { + const thresholds = customThresholds ?? { + cool: 30, // < 30%: Cool (cyan) + warm: 40, // 30-40%: Warm (green/yellow) + hot: 55, // 40-55%: Hot (orange) - "very hot" + veryHot: 70 // 55-70%: Very hot (orange-red) + // 70%+: Critical (red) + }; + + // Apply colors based on thresholds + if (percentage < thresholds.cool) { + return 'hex:00D9FF'; // Cyan - cool, plenty of space + } else if (percentage < thresholds.warm) { + return 'hex:4ADE80'; // Green - comfortable + } else if (percentage < thresholds.hot) { + return 'hex:FDE047'; // Yellow - "pretty hot", getting warm + } else if (percentage < thresholds.veryHot) { + return 'hex:FB923C'; // Orange - "very hot", concerning + } else { + return 'hex:F87171'; // Red - critical, take action + } +} + export function getChalkColor(colorName: string | undefined, colorLevel: 'ansi16' | 'ansi256' | 'truecolor' = 'ansi16', isBackground = false): ChalkInstance | undefined { if (!colorName) return undefined; diff --git a/src/widgets/ContextPercentage.ts b/src/widgets/ContextPercentage.ts index 7111dbb7..9a20c37a 100644 --- a/src/widgets/ContextPercentage.ts +++ b/src/widgets/ContextPercentage.ts @@ -1,3 +1,5 @@ +import chalk from 'chalk'; + import type { RenderContext } from '../types/RenderContext'; import type { Settings } from '../types/Settings'; import type { @@ -6,6 +8,10 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { + getChalkColor, + getHeatGaugeColor +} from '../utils/colors'; import { getContextConfig } from '../utils/model-context'; export class ContextPercentageWidget implements Widget { @@ -45,14 +51,27 @@ export class ContextPercentageWidget implements Widget { if (context.isPreview) { const previewValue = isInverse ? '90.7%' : '9.3%'; - return item.rawValue ? previewValue : `Ctx: ${previewValue}`; + const previewPercentage = isInverse ? 90.7 : 9.3; + const heatColor = getHeatGaugeColor(previewPercentage, settings.heatGaugeThresholds); + const chalkColor = getChalkColor(heatColor, 'truecolor'); + const coloredValue = chalkColor ? chalkColor(previewValue) : previewValue; + return item.rawValue ? coloredValue : `Ctx: ${coloredValue}`; } else if (context.tokenMetrics) { 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); const displayPercentage = isInverse ? (100 - usedPercentage) : usedPercentage; - return item.rawValue ? `${displayPercentage.toFixed(1)}%` : `Ctx: ${displayPercentage.toFixed(1)}%`; + const percentageString = `${displayPercentage.toFixed(1)}%`; + + // Apply heat gauge color based on displayed percentage + // Heat gauge colors override widget-level colors to ensure + // consistent visual feedback for context usage levels + const heatColor = getHeatGaugeColor(displayPercentage, settings.heatGaugeThresholds); + const chalkColor = getChalkColor(heatColor, 'truecolor'); + const coloredPercentage = chalkColor ? chalkColor(percentageString) : percentageString; + + return item.rawValue ? coloredPercentage : `Ctx: ${coloredPercentage}`; } return null; } diff --git a/src/widgets/ContextPercentageUsable.ts b/src/widgets/ContextPercentageUsable.ts index 7ab5446c..1581a7d9 100644 --- a/src/widgets/ContextPercentageUsable.ts +++ b/src/widgets/ContextPercentageUsable.ts @@ -6,6 +6,10 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { + getChalkColor, + getHeatGaugeColor +} from '../utils/colors'; import { getContextConfig } from '../utils/model-context'; export class ContextPercentageUsableWidget implements Widget { @@ -14,15 +18,17 @@ export class ContextPercentageUsableWidget implements Widget { getDisplayName(): string { return 'Context % (usable)'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { const isInverse = item.metadata?.inverse === 'true'; + const heatGaugeOn = item.heatGaugeColors ?? true; const modifiers: string[] = []; if (isInverse) { modifiers.push('remaining'); } + modifiers.push(`heat:${heatGaugeOn ? 'ON' : 'OFF'}`); return { displayText: this.getDisplayName(), - modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined + modifierText: `(${modifiers.join(', ')})` }; } @@ -37,29 +43,69 @@ export class ContextPercentageUsableWidget implements Widget { } }; } + if (action === 'toggle-heat-gauge') { + return { ...item, heatGaugeColors: !(item.heatGaugeColors ?? true) }; + } return null; } render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { const isInverse = item.metadata?.inverse === 'true'; + const useHeatGauge = item.heatGaugeColors ?? true; if (context.isPreview) { const previewValue = isInverse ? '88.4%' : '11.6%'; + const previewPercentage = isInverse ? 88.4 : 11.6; + if (useHeatGauge) { + const heatColor = getHeatGaugeColor(previewPercentage, settings.heatGaugeThresholds); + const chalkColor = getChalkColor(heatColor, 'truecolor'); + const coloredValue = chalkColor ? chalkColor(previewValue) : previewValue; + return item.rawValue ? coloredValue : `Ctx(u): ${coloredValue}`; + } return item.rawValue ? previewValue : `Ctx(u): ${previewValue}`; - } else if (context.tokenMetrics) { + } + + // Prefer context_window data from Claude Code (v2.0.65+) + if (context.contextWindow && context.contextWindow.contextWindowSize > 0) { + const usableTokens = context.contextWindow.contextWindowSize * 0.8; + const usedPercentage = Math.min(100, (context.contextWindow.totalInputTokens / usableTokens) * 100); + const displayPercentage = isInverse ? (100 - usedPercentage) : usedPercentage; + const percentageString = `${displayPercentage.toFixed(1)}%`; + + if (useHeatGauge) { + const heatColor = getHeatGaugeColor(displayPercentage, settings.heatGaugeThresholds); + const chalkColor = getChalkColor(heatColor, 'truecolor'); + const coloredPercentage = chalkColor ? chalkColor(percentageString) : percentageString; + return item.rawValue ? coloredPercentage : `Ctx(u): ${coloredPercentage}`; + } + return item.rawValue ? percentageString : `Ctx(u): ${percentageString}`; + } + + // Fall back to transcript-based metrics with model lookup + if (context.tokenMetrics) { 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.usableTokens) * 100); const displayPercentage = isInverse ? (100 - usedPercentage) : usedPercentage; - return item.rawValue ? `${displayPercentage.toFixed(1)}%` : `Ctx(u): ${displayPercentage.toFixed(1)}%`; + const percentageString = `${displayPercentage.toFixed(1)}%`; + + if (useHeatGauge) { + const heatColor = getHeatGaugeColor(displayPercentage, settings.heatGaugeThresholds); + const chalkColor = getChalkColor(heatColor, 'truecolor'); + const coloredPercentage = chalkColor ? chalkColor(percentageString) : percentageString; + return item.rawValue ? coloredPercentage : `Ctx(u): ${coloredPercentage}`; + } + return item.rawValue ? percentageString : `Ctx(u): ${percentageString}`; } + return null; } getCustomKeybinds(): CustomKeybind[] { return [ - { key: 'l', label: '(l)eft/remaining', action: 'toggle-inverse' } + { key: 'l', label: '(l)eft/remaining', action: 'toggle-inverse' }, + { key: 'h', label: '(h)eat gauge on/off', action: 'toggle-heat-gauge' } ]; } diff --git a/src/widgets/__tests__/ContextPercentage.test.ts b/src/widgets/__tests__/ContextPercentage.test.ts index 111e672f..bd49d63f 100644 --- a/src/widgets/__tests__/ContextPercentage.test.ts +++ b/src/widgets/__tests__/ContextPercentage.test.ts @@ -1,4 +1,6 @@ +import chalk from 'chalk'; import { + beforeAll, describe, expect, it @@ -11,6 +13,17 @@ import type { import { DEFAULT_SETTINGS } from '../../types/Settings'; import { ContextPercentageWidget } from '../ContextPercentage'; +// Force enable colors in test environment to verify color application +beforeAll(() => { + chalk.level = 3; // Enable truecolor support +}); + +// Helper to strip ANSI color codes for testing +function stripAnsi(str: string): string { + // Match all ANSI escape sequences including truecolor (38;2;R;G;B) + return str.replace(/\u001b\[[^m]*m/g, ''); +} + function render(modelId: string | undefined, contextLength: number, rawValue = false, inverse = false) { const widget = new ContextPercentageWidget(); const context: RenderContext = { @@ -34,32 +47,148 @@ function render(modelId: string | undefined, contextLength: number, rawValue = f } describe('ContextPercentageWidget', () => { + describe('Heat gauge colors', () => { + it('should apply colors to low percentage values', () => { + // 5% usage - should have cyan color (ANSI codes present) + const result = render('claude-3-5-sonnet-20241022', 10000); + expect(result).toContain('5.0%'); + // Verify ANSI escape codes are present (colors applied) + expect(result).toMatch(/\x1b\[/); // ANSI escape sequence present + }); + + it('should apply colors to high percentage values', () => { + // 90% usage - should have red color (ANSI codes present) + const result = render('claude-3-5-sonnet-20241022', 180000); + expect(result).toContain('90.0%'); + // Verify ANSI escape codes are present (colors applied) + expect(result).toMatch(/\x1b\[/); // ANSI escape sequence present + }); + + it('should apply different colors for low vs high percentages', () => { + const lowResult = render('claude-3-5-sonnet-20241022', 10000); // 5% + const highResult = render('claude-3-5-sonnet-20241022', 180000); // 90% + // The ANSI color codes should be different + expect(highResult).not.toBeNull(); + expect(lowResult).not.toBe(highResult?.replace('90.0%', '5.0%')); + }); + + it('should apply colors in raw value mode', () => { + const result = render('claude-3-5-sonnet-20241022', 10000, true); + expect(result).toContain('5.0%'); + expect(result).not.toContain('Ctx:'); + // Verify ANSI escape codes are present + expect(result).toMatch(/\x1b\[/); + }); + }); + + describe('Heat gauge colors in inverse mode', () => { + it('should apply cool color when showing low remaining percentage', () => { + // 90% used = 10% remaining (should show cool cyan color) + const result = render('claude-3-5-sonnet-20241022', 180000, false, true); + expect(result).toContain('10.0%'); // Shows remaining + // Verify colors are applied + expect(result).toMatch(/\x1b\[/); + }); + + it('should apply hot color when showing high remaining percentage', () => { + // 10% used = 90% remaining (should show hot red color) + const result = render('claude-3-5-sonnet-20241022', 20000, false, true); + expect(result).toContain('90.0%'); // Shows remaining + // Verify colors are applied + expect(result).toMatch(/\x1b\[/); + }); + + it('should color based on displayed percentage not usage', () => { + // Same usage level should produce different colors in normal vs inverse mode + const normalResult = render('claude-3-5-sonnet-20241022', 180000, false, false); // 90% used + const inverseResult = render('claude-3-5-sonnet-20241022', 180000, false, true); // 10% remaining + // The color codes should differ because displayed percentages differ (90% vs 10%) + expect(normalResult).not.toBe(inverseResult); + }); + }); + describe('Sonnet 4.5 with 1M context window', () => { it('should calculate percentage using 1M denominator for Sonnet 4.5 with [1m] suffix', () => { const result = render('claude-sonnet-4-5-20250929[1m]', 42000); - expect(result).toBe('Ctx: 4.2%'); + // Strip ANSI codes to check the percentage value + expect(result).not.toBeNull(); + expect(stripAnsi(result!)).toBe('Ctx: 4.2%'); }); it('should calculate percentage using 1M denominator for Sonnet 4.5 (raw value) with [1m] suffix', () => { const result = render('claude-sonnet-4-5-20250929[1m]', 42000, true); - expect(result).toBe('4.2%'); + // Strip ANSI codes to check the percentage value + expect(result).not.toBeNull(); + expect(stripAnsi(result!)).toBe('4.2%'); }); }); describe('Older models with 200k context window', () => { it('should calculate percentage using 200k denominator for older Sonnet 3.5', () => { const result = render('claude-3-5-sonnet-20241022', 42000); - expect(result).toBe('Ctx: 21.0%'); + // Strip ANSI codes to check the percentage value + expect(result).not.toBeNull(); + expect(stripAnsi(result!)).toBe('Ctx: 21.0%'); }); it('should calculate percentage using 200k denominator when model ID is undefined', () => { const result = render(undefined, 42000); - expect(result).toBe('Ctx: 21.0%'); + // Strip ANSI codes to check the percentage value + expect(result).not.toBeNull(); + expect(stripAnsi(result!)).toBe('Ctx: 21.0%'); }); it('should calculate percentage using 200k denominator for unknown model', () => { const result = render('claude-unknown-model', 42000); - expect(result).toBe('Ctx: 21.0%'); + // Strip ANSI codes to check the percentage value + expect(result).not.toBeNull(); + expect(stripAnsi(result!)).toBe('Ctx: 21.0%'); + }); + }); + + describe('Custom heat gauge thresholds from settings', () => { + function renderWithThresholds( + modelId: string | undefined, + contextLength: number, + thresholds: { cool: number; warm: number; hot: number; veryHot: number } | undefined + ) { + const widget = new ContextPercentageWidget(); + const context: RenderContext = { + data: modelId ? { model: { id: modelId } } : undefined, + tokenMetrics: { + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + totalTokens: 0, + contextLength + } + }; + const item: WidgetItem = { + id: 'context-percentage', + type: 'context-percentage' + }; + const settings = { + ...DEFAULT_SETTINGS, + heatGaugeThresholds: thresholds + }; + return widget.render(item, context, settings); + } + + it('should use custom thresholds when configured', () => { + // 25% usage with custom cool=20 should produce green + // With defaults, 25% < 30% (cool) would produce cyan + const result = renderWithThresholds('claude-3-5-sonnet-20241022', 50000, { cool: 20, warm: 35, hot: 50, veryHot: 65 }); + expect(result).not.toBeNull(); + // Green = rgb(74, 222, 128) = hex:4ADE80 + expect(result).toMatch(/\x1b\[38;2;74;222;128m/); + }); + + it('should fall back to defaults when no custom thresholds configured', () => { + // 5% usage with no custom thresholds = cyan (< default cool=30%) + const result = renderWithThresholds('claude-3-5-sonnet-20241022', 10000, undefined); + expect(result).not.toBeNull(); + // Cyan = rgb(0, 217, 255) = hex:00D9FF + expect(result).toMatch(/\x1b\[38;2;0;217;255m/); }); }); }); \ No newline at end of file diff --git a/src/widgets/__tests__/ContextPercentageUsable.test.ts b/src/widgets/__tests__/ContextPercentageUsable.test.ts index 60595a07..948ccc46 100644 --- a/src/widgets/__tests__/ContextPercentageUsable.test.ts +++ b/src/widgets/__tests__/ContextPercentageUsable.test.ts @@ -1,4 +1,6 @@ +import chalk from 'chalk'; import { + beforeAll, describe, expect, it @@ -11,6 +13,11 @@ import type { import { DEFAULT_SETTINGS } from '../../types/Settings'; import { ContextPercentageUsableWidget } from '../ContextPercentageUsable'; +// Force enable colors in test environment to verify color application +beforeAll(() => { + chalk.level = 3; // Enable truecolor support +}); + function render(modelId: string | undefined, contextLength: number, rawValue = false, inverse = false) { const widget = new ContextPercentageUsableWidget(); const context: RenderContext = { @@ -27,6 +34,7 @@ function render(modelId: string | undefined, contextLength: number, rawValue = f id: 'context-percentage-usable', type: 'context-percentage-usable', rawValue, + heatGaugeColors: false, metadata: inverse ? { inverse: 'true' } : undefined }; @@ -62,4 +70,48 @@ describe('ContextPercentageUsableWidget', () => { expect(result).toBe('Ctx(u): 26.3%'); }); }); + + describe('Heat gauge colors', () => { + it('should apply colors when heatGaugeColors is true (default)', () => { + 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: 10000 + } + }; + const item: WidgetItem = { + id: 'context-percentage-usable', + type: 'context-percentage-usable' + }; + const result = widget.render(item, context, DEFAULT_SETTINGS); + expect(result).toContain('6.3%'); + expect(result).toMatch(/\x1b\[/); // ANSI escape sequence present + }); + + it('should not apply colors when heatGaugeColors is false', () => { + 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: 10000 + } + }; + const item: WidgetItem = { + id: 'context-percentage-usable', + type: 'context-percentage-usable', + heatGaugeColors: false + }; + const result = widget.render(item, context, DEFAULT_SETTINGS); + expect(result).toBe('Ctx(u): 6.3%'); + }); + }); }); \ No newline at end of file diff --git a/src/widgets/__tests__/GitWorktree.test.ts b/src/widgets/__tests__/GitWorktree.test.ts index 79a43d8e..261577a9 100644 --- a/src/widgets/__tests__/GitWorktree.test.ts +++ b/src/widgets/__tests__/GitWorktree.test.ts @@ -78,4 +78,4 @@ describe('GitWorktreeWidget', () => { expect(render()).toBe('𖠰 no git'); }); -}); +}); \ No newline at end of file