From f554e7908faccfd8afe70270d59301fc4837638c Mon Sep 17 00:00:00 2001 From: Ethan Stark Date: Thu, 1 Jan 2026 02:06:47 -0500 Subject: [PATCH 01/13] feat: add heat gauge colors to context percentage widget --- src/widgets/ContextPercentage.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/widgets/ContextPercentage.ts b/src/widgets/ContextPercentage.ts index 7111dbb7..ede8ac86 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,7 @@ 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 +48,25 @@ 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); + 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 + const heatColor = getHeatGaugeColor(displayPercentage); + const chalkColor = getChalkColor(heatColor, 'truecolor'); + const coloredPercentage = chalkColor ? chalkColor(percentageString) : percentageString; + + return item.rawValue ? coloredPercentage : `Ctx: ${coloredPercentage}`; } return null; } From 723fc6226d454bfed4b1b350aa854ff59cbd3045 Mon Sep 17 00:00:00 2001 From: Ethan Stark Date: Thu, 1 Jan 2026 02:07:05 -0500 Subject: [PATCH 02/13] docs: document heat gauge color precedence --- src/widgets/ContextPercentage.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/widgets/ContextPercentage.ts b/src/widgets/ContextPercentage.ts index ede8ac86..3b3271c7 100644 --- a/src/widgets/ContextPercentage.ts +++ b/src/widgets/ContextPercentage.ts @@ -62,6 +62,8 @@ export class ContextPercentageWidget implements Widget { 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); const chalkColor = getChalkColor(heatColor, 'truecolor'); const coloredPercentage = chalkColor ? chalkColor(percentageString) : percentageString; From b721707524f6417c155517239792806a096e2cd2 Mon Sep 17 00:00:00 2001 From: Ethan Stark Date: Thu, 1 Jan 2026 02:09:06 -0500 Subject: [PATCH 03/13] test: add heat gauge color tests for context percentage widget --- .../__tests__/ContextPercentage.test.ts | 87 +++++++++++++++++-- 1 file changed, 82 insertions(+), 5 deletions(-) diff --git a/src/widgets/__tests__/ContextPercentage.test.ts b/src/widgets/__tests__/ContextPercentage.test.ts index 111e672f..f8fdb169 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 { + // eslint-disable-next-line no-control-regex + return str.replace(/\u001b\[\d+m/g, ''); +} + function render(modelId: string | undefined, contextLength: number, rawValue = false, inverse = false) { const widget = new ContextPercentageWidget(); const context: RenderContext = { @@ -34,32 +47,96 @@ 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(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(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(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(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(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(stripAnsi(result)).toBe('Ctx: 21.0%'); }); }); }); \ No newline at end of file From 496d3ef30ed6581b3c1a33f25e8918818ef4ff0b Mon Sep 17 00:00:00 2001 From: Ethan Stark Date: Thu, 1 Jan 2026 03:02:41 -0500 Subject: [PATCH 04/13] feat: add model-aware heat gauge color thresholds - Add is1MModel parameter to getHeatGaugeColor() - Define conservative thresholds: 40%/55% for standard, 10%/15% for [1m] - Add comprehensive test coverage for both model types - Maintain backward compatibility (defaults to standard thresholds) --- src/utils/__tests__/colors.test.ts | 86 ++++++++++++++++++++++++++++++ src/utils/colors.ts | 44 +++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 src/utils/__tests__/colors.test.ts diff --git a/src/utils/__tests__/colors.test.ts b/src/utils/__tests__/colors.test.ts new file mode 100644 index 00000000..d1d3ec11 --- /dev/null +++ b/src/utils/__tests__/colors.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import { getHeatGaugeColor } from '../colors'; + +describe('getHeatGaugeColor', () => { + describe('Standard models (200k context)', () => { + it('should return cool cyan for low usage (< 30%)', () => { + const color = getHeatGaugeColor(10, false); + expect(color).toBe('hex:00D9FF'); // Cyan - cool + }); + + it('should return green for comfortable range (30-40%)', () => { + const color = getHeatGaugeColor(35, false); + expect(color).toBe('hex:4ADE80'); // Green - comfortable + }); + + it('should return yellow at pretty hot threshold (40%)', () => { + const color = getHeatGaugeColor(40, false); + expect(color).toBe('hex:FDE047'); // Yellow - pretty hot + }); + + it('should return yellow in pretty hot range (40-55%)', () => { + const color = getHeatGaugeColor(50, false); + expect(color).toBe('hex:FDE047'); // Yellow - pretty hot + }); + + it('should return orange at very hot threshold (55%)', () => { + const color = getHeatGaugeColor(55, false); + expect(color).toBe('hex:FB923C'); // Orange - very hot + }); + + it('should return orange in very hot range (55-70%)', () => { + const color = getHeatGaugeColor(65, false); + expect(color).toBe('hex:FB923C'); // Orange - very hot + }); + + it('should return red for high usage (70%+)', () => { + const color = getHeatGaugeColor(70, false); + expect(color).toBe('hex:F87171'); // Red - critical + }); + }); + + describe('[1m] models (1M context)', () => { + it('should return cool cyan for low usage (< 8%)', () => { + const color = getHeatGaugeColor(5, true); + expect(color).toBe('hex:00D9FF'); // Cyan - cool + }); + + it('should return green for comfortable range (8-10%)', () => { + const color = getHeatGaugeColor(9, true); + expect(color).toBe('hex:4ADE80'); // Green - comfortable + }); + + it('should return yellow at pretty hot threshold (10%)', () => { + const color = getHeatGaugeColor(10, true); + expect(color).toBe('hex:FDE047'); // Yellow - pretty hot + }); + + it('should return yellow in pretty hot range (10-15%)', () => { + const color = getHeatGaugeColor(12, true); + expect(color).toBe('hex:FDE047'); // Yellow - pretty hot + }); + + it('should return orange at very hot threshold (15%)', () => { + const color = getHeatGaugeColor(15, true); + expect(color).toBe('hex:FB923C'); // Orange - very hot + }); + + it('should return orange in very hot range (15-20%)', () => { + const color = getHeatGaugeColor(18, true); + expect(color).toBe('hex:FB923C'); // Orange - very hot + }); + + it('should return red for high usage (20%+)', () => { + const color = getHeatGaugeColor(20, true); + expect(color).toBe('hex:F87171'); // Red - critical + }); + }); + + describe('Backward compatibility', () => { + it('should use standard model thresholds when is1MModel is undefined', () => { + // @ts-expect-error Testing backward compatibility + const color = getHeatGaugeColor(40); + expect(color).toBe('hex:FDE047'); // Should use standard model thresholds + }); + }); +}); diff --git a/src/utils/colors.ts b/src/utils/colors.ts index 7fc549cb..fd739601 100644 --- a/src/utils/colors.ts +++ b/src/utils/colors.ts @@ -82,6 +82,50 @@ export function bgToFg(colorName: string | undefined): string | undefined { return colorName; } +/** + * Maps a percentage value (0-100) to a heat gauge color. + * Uses different thresholds for [1m] models vs standard models: + * - Standard models (200k): Conservative thresholds (40%, 55%, 70%) + * - [1m] models (1M): Very conservative thresholds (10%, 15%, 20%) + * + * Colors are selected to be visible in both light and dark terminal themes. + * + * @param percentage - The percentage value (0-100) + * @param is1MModel - Whether this is a [1m] model with 1M context (default: false) + * @returns A color name string compatible with getChalkColor + */ +export function getHeatGaugeColor(percentage: number, is1MModel = false): string { + // Define thresholds based on model type + const thresholds = is1MModel + ? { + cool: 8, // < 8%: Cool (cyan) + warm: 10, // 8-10%: Warm (green/yellow) - "pretty hot" + hot: 15, // 10-15%: Hot (orange) - "very hot" + veryHot: 20 // 15-20%: Very hot (orange-red) + // 20%+: Critical (red) + } + : { + 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; From a70388fce252f9b2dcd98b5c7c40e72c0b1d10c7 Mon Sep 17 00:00:00 2001 From: Ethan Stark Date: Thu, 1 Jan 2026 03:03:40 -0500 Subject: [PATCH 05/13] feat: apply model-aware heat gauge colors in context percentage widget - Pass is1MModel flag to getHeatGaugeColor() based on context config - [1m] models now use conservative 10%/15% thresholds - Standard models use 40%/55% thresholds - Add comprehensive tests for model-aware color application --- src/widgets/ContextPercentage.ts | 10 +++-- .../__tests__/ContextPercentage.test.ts | 42 ++++++++++++++++++- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/widgets/ContextPercentage.ts b/src/widgets/ContextPercentage.ts index 3b3271c7..fa5800af 100644 --- a/src/widgets/ContextPercentage.ts +++ b/src/widgets/ContextPercentage.ts @@ -49,7 +49,8 @@ export class ContextPercentageWidget implements Widget { if (context.isPreview) { const previewValue = isInverse ? '90.7%' : '9.3%'; const previewPercentage = isInverse ? 90.7 : 9.3; - const heatColor = getHeatGaugeColor(previewPercentage); + // For preview, assume standard model (most common case) + const heatColor = getHeatGaugeColor(previewPercentage, false); const chalkColor = getChalkColor(heatColor, 'truecolor'); const coloredValue = chalkColor ? chalkColor(previewValue) : previewValue; return item.rawValue ? coloredValue : `Ctx: ${coloredValue}`; @@ -61,10 +62,13 @@ export class ContextPercentageWidget implements Widget { const displayPercentage = isInverse ? (100 - usedPercentage) : usedPercentage; const percentageString = `${displayPercentage.toFixed(1)}%`; - // Apply heat gauge color based on displayed percentage + // Determine if this is a [1m] model for heat gauge thresholds + const is1MModel = contextConfig.maxTokens === 1000000; + + // Apply heat gauge color based on displayed percentage and model type // Heat gauge colors override widget-level colors to ensure // consistent visual feedback for context usage levels - const heatColor = getHeatGaugeColor(displayPercentage); + const heatColor = getHeatGaugeColor(displayPercentage, is1MModel); const chalkColor = getChalkColor(heatColor, 'truecolor'); const coloredPercentage = chalkColor ? chalkColor(percentageString) : percentageString; diff --git a/src/widgets/__tests__/ContextPercentage.test.ts b/src/widgets/__tests__/ContextPercentage.test.ts index f8fdb169..61f86520 100644 --- a/src/widgets/__tests__/ContextPercentage.test.ts +++ b/src/widgets/__tests__/ContextPercentage.test.ts @@ -21,7 +21,8 @@ beforeAll(() => { // Helper to strip ANSI color codes for testing function stripAnsi(str: string): string { // eslint-disable-next-line no-control-regex - return str.replace(/\u001b\[\d+m/g, ''); + // 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) { @@ -106,6 +107,45 @@ describe('ContextPercentageWidget', () => { }); }); + describe('Model-aware heat gauge colors', () => { + it('should use [1m] model thresholds for Sonnet 4.5 with [1m] suffix', () => { + // 10% usage on [1m] model should trigger "pretty hot" color (yellow) + const result = render('claude-sonnet-4-5-20250929[1m]', 100000); // 10% of 1M + expect(result).toContain('10.0%'); + // Verify yellow color code is present (FDE047 = rgb(253, 224, 71)) + expect(result).toMatch(/\x1b\[38;2;253;224;71m/); // RGB for FDE047 + }); + + it('should use standard model thresholds for older models', () => { + // 10% usage on standard model should still be cool (cyan) + const result = render('claude-3-5-sonnet-20241022', 20000); // 10% of 200k + expect(result).toContain('10.0%'); + // Verify cyan color code is present (00D9FF = rgb(0, 217, 255)) + expect(result).toMatch(/\x1b\[38;2;0;217;255m/); // RGB for 00D9FF + }); + + it('should differentiate pretty hot thresholds between model types', () => { + // 40% on standard model = yellow (pretty hot) + const standardResult = render('claude-3-5-sonnet-20241022', 80000); // 40% of 200k + expect(stripAnsi(standardResult)).toContain('40.0%'); + + // 40% on [1m] model = red (critical) + const model1MResult = render('claude-sonnet-4-5-20250929[1m]', 400000); // 40% of 1M + expect(stripAnsi(model1MResult)).toContain('40.0%'); + + // Colors should be very different + expect(standardResult).not.toBe(model1MResult); + }); + + it('should handle inverse mode with model-aware colors', () => { + // [1m] model: 85% used = 15% remaining (should be warm/hot threshold) + const result = render('claude-sonnet-4-5-20250929[1m]', 850000, false, true); + expect(result).toContain('15.0%'); + // Should show orange color (15% threshold for [1m] models) + expect(result).toMatch(/\x1b\[38;2;251;146;60m/); // RGB for FB923C + }); + }); + 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); From 378d7834f575079e07304806641335acd58ed620 Mon Sep 17 00:00:00 2001 From: Ethan Stark Date: Thu, 1 Jan 2026 03:05:09 -0500 Subject: [PATCH 06/13] docs: document model-aware heat gauge color behavior - Add detailed JSDoc for getHeatGaugeColor function - Document threshold rationale for both model types - Explain why [1m] models need more conservative thresholds - Add usage examples showing different model behaviors - Document color selection from Tailwind CSS palette --- src/utils/colors.ts | 37 +++++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/src/utils/colors.ts b/src/utils/colors.ts index fd739601..23eace66 100644 --- a/src/utils/colors.ts +++ b/src/utils/colors.ts @@ -83,16 +83,41 @@ export function bgToFg(colorName: string | undefined): string | undefined { } /** - * Maps a percentage value (0-100) to a heat gauge color. + * Maps a percentage value (0-100) to a heat gauge color based on model type. + * * Uses different thresholds for [1m] models vs standard models: - * - Standard models (200k): Conservative thresholds (40%, 55%, 70%) - * - [1m] models (1M): Very conservative thresholds (10%, 15%, 20%) + * - **Standard models (200k context)**: Conservative thresholds + * - < 30%: Cool cyan - plenty of space + * - 30-40%: Comfortable green + * - 40-55%: "Pretty hot" yellow - getting warm (40% threshold) + * - 55-70%: "Very hot" orange - concerning (55% threshold) + * - 70%+: Critical red - take action + * + * - **[1m] models (1M context)**: Very conservative thresholds + * - < 8%: Cool cyan - plenty of space + * - 8-10%: Comfortable green + * - 10-15%: "Pretty hot" yellow - getting warm (10% threshold) + * - 15-20%: "Very hot" orange - concerning (15% threshold) + * - 20%+: 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. * - * Colors are selected to be visible in both light and dark terminal themes. + * **Usage**: Called by ContextPercentage widget 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) + * @param percentage - The percentage value (0-100) to colorize * @param is1MModel - Whether this is a [1m] model with 1M context (default: false) - * @returns A color name string compatible with getChalkColor + * @returns A color name string in hex:XXXXXX format, compatible with getChalkColor + * + * @example + * // Standard model at 45% usage - shows yellow (pretty hot) + * const color = getHeatGaugeColor(45, false); // Returns 'hex:FDE047' + * + * @example + * // [1m] model at 12% usage - shows yellow (pretty hot) + * const color = getHeatGaugeColor(12, true); // Returns 'hex:FDE047' */ export function getHeatGaugeColor(percentage: number, is1MModel = false): string { // Define thresholds based on model type From 2699538dbdf799c99138d68083367a3553b7a8c4 Mon Sep 17 00:00:00 2001 From: Ethan Stark Date: Thu, 1 Jan 2026 03:08:41 -0500 Subject: [PATCH 07/13] fix: add null checks and remove unused ts-expect-error - Add null assertions in ContextPercentage tests to satisfy TypeScript - Remove unused @ts-expect-error directive in colors tests - All tests passing, TypeScript compilation successful --- src/utils/__tests__/colors.test.ts | 10 +++++--- .../__tests__/ContextPercentage.test.ts | 25 ++++++++++++------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/utils/__tests__/colors.test.ts b/src/utils/__tests__/colors.test.ts index d1d3ec11..85339369 100644 --- a/src/utils/__tests__/colors.test.ts +++ b/src/utils/__tests__/colors.test.ts @@ -1,4 +1,9 @@ -import { describe, expect, it } from 'vitest'; +import { + describe, + expect, + it +} from 'vitest'; + import { getHeatGaugeColor } from '../colors'; describe('getHeatGaugeColor', () => { @@ -78,9 +83,8 @@ describe('getHeatGaugeColor', () => { describe('Backward compatibility', () => { it('should use standard model thresholds when is1MModel is undefined', () => { - // @ts-expect-error Testing backward compatibility const color = getHeatGaugeColor(40); expect(color).toBe('hex:FDE047'); // Should use standard model thresholds }); }); -}); +}); \ No newline at end of file diff --git a/src/widgets/__tests__/ContextPercentage.test.ts b/src/widgets/__tests__/ContextPercentage.test.ts index 61f86520..6e37bf15 100644 --- a/src/widgets/__tests__/ContextPercentage.test.ts +++ b/src/widgets/__tests__/ContextPercentage.test.ts @@ -20,7 +20,6 @@ beforeAll(() => { // Helper to strip ANSI color codes for testing function stripAnsi(str: string): string { - // eslint-disable-next-line no-control-regex // Match all ANSI escape sequences including truecolor (38;2;R;G;B) return str.replace(/\u001b\[[^m]*m/g, ''); } @@ -69,7 +68,8 @@ describe('ContextPercentageWidget', () => { 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(lowResult).not.toBe(highResult.replace('90.0%', '5.0%')); + expect(highResult).not.toBeNull(); + expect(lowResult).not.toBe(highResult?.replace('90.0%', '5.0%')); }); it('should apply colors in raw value mode', () => { @@ -127,11 +127,13 @@ describe('ContextPercentageWidget', () => { it('should differentiate pretty hot thresholds between model types', () => { // 40% on standard model = yellow (pretty hot) const standardResult = render('claude-3-5-sonnet-20241022', 80000); // 40% of 200k - expect(stripAnsi(standardResult)).toContain('40.0%'); + expect(standardResult).not.toBeNull(); + expect(stripAnsi(standardResult!)).toContain('40.0%'); // 40% on [1m] model = red (critical) const model1MResult = render('claude-sonnet-4-5-20250929[1m]', 400000); // 40% of 1M - expect(stripAnsi(model1MResult)).toContain('40.0%'); + expect(model1MResult).not.toBeNull(); + expect(stripAnsi(model1MResult!)).toContain('40.0%'); // Colors should be very different expect(standardResult).not.toBe(model1MResult); @@ -150,13 +152,15 @@ describe('ContextPercentageWidget', () => { it('should calculate percentage using 1M denominator for Sonnet 4.5 with [1m] suffix', () => { const result = render('claude-sonnet-4-5-20250929[1m]', 42000); // Strip ANSI codes to check the percentage value - expect(stripAnsi(result)).toBe('Ctx: 4.2%'); + 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); // Strip ANSI codes to check the percentage value - expect(stripAnsi(result)).toBe('4.2%'); + expect(result).not.toBeNull(); + expect(stripAnsi(result!)).toBe('4.2%'); }); }); @@ -164,19 +168,22 @@ describe('ContextPercentageWidget', () => { it('should calculate percentage using 200k denominator for older Sonnet 3.5', () => { const result = render('claude-3-5-sonnet-20241022', 42000); // Strip ANSI codes to check the percentage value - expect(stripAnsi(result)).toBe('Ctx: 21.0%'); + 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); // Strip ANSI codes to check the percentage value - expect(stripAnsi(result)).toBe('Ctx: 21.0%'); + 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); // Strip ANSI codes to check the percentage value - expect(stripAnsi(result)).toBe('Ctx: 21.0%'); + expect(result).not.toBeNull(); + expect(stripAnsi(result!)).toBe('Ctx: 21.0%'); }); }); }); \ No newline at end of file From 41cae8214e82d18b26a5b5986c6a050b752040d4 Mon Sep 17 00:00:00 2001 From: Ethan Stark Date: Wed, 18 Feb 2026 22:06:01 -0500 Subject: [PATCH 08/13] feat: add HeatGaugeThresholds schema to Settings type --- src/types/Settings.ts | 16 +++++ src/types/__tests__/Settings.test.ts | 91 ++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 src/types/__tests__/Settings.test.ts diff --git a/src/types/Settings.ts b/src/types/Settings.ts index ebde1fd7..24e72621 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,6 +73,10 @@ export const SettingsSchema = z.object({ updatemessage: z.object({ message: z.string().nullable().optional(), remaining: z.number().nullable().optional() + }).optional(), + heatGaugeThresholds: z.object({ + standard: HeatGaugeThresholdSetSchema.optional(), + extended: HeatGaugeThresholdSetSchema.optional() }).optional() }); diff --git a/src/types/__tests__/Settings.test.ts b/src/types/__tests__/Settings.test.ts new file mode 100644 index 00000000..20fb7eb5 --- /dev/null +++ b/src/types/__tests__/Settings.test.ts @@ -0,0 +1,91 @@ +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 standard thresholds', () => { + const result = SettingsSchema.safeParse({ + heatGaugeThresholds: { + standard: { cool: 20, warm: 35, hot: 50, veryHot: 65 } + } + }); + expect(result.success).toBe(true); + }); + + it('should accept valid extended thresholds', () => { + const result = SettingsSchema.safeParse({ + heatGaugeThresholds: { + extended: { cool: 5, warm: 8, hot: 12, veryHot: 18 } + } + }); + expect(result.success).toBe(true); + }); + + it('should accept both standard and extended thresholds', () => { + const result = SettingsSchema.safeParse({ + heatGaugeThresholds: { + standard: { cool: 20, warm: 35, hot: 50, veryHot: 65 }, + extended: { cool: 5, warm: 8, hot: 12, veryHot: 18 } + } + }); + expect(result.success).toBe(true); + }); + + it('should reject thresholds where cool >= warm', () => { + const result = SettingsSchema.safeParse({ + heatGaugeThresholds: { + standard: { 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: { + standard: { 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: { + standard: { cool: 20, warm: 35, hot: 70, veryHot: 65 } + } + }); + expect(result.success).toBe(false); + }); + + it('should reject thresholds below 0', () => { + const result = SettingsSchema.safeParse({ + heatGaugeThresholds: { + standard: { cool: -5, warm: 35, hot: 50, veryHot: 65 } + } + }); + expect(result.success).toBe(false); + }); + + it('should reject thresholds above 100', () => { + const result = SettingsSchema.safeParse({ + heatGaugeThresholds: { + standard: { 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: { + standard: { cool: 20, warm: 35 } + } + }); + expect(result.success).toBe(false); + }); +}); From 938cb53ce28b30204e0ad36515a35b460b8db60a Mon Sep 17 00:00:00 2001 From: Ethan Stark Date: Wed, 18 Feb 2026 22:06:46 -0500 Subject: [PATCH 09/13] feat: accept optional custom thresholds in getHeatGaugeColor --- src/utils/__tests__/colors.test.ts | 30 ++++++++++++++++++++++++++++++ src/utils/colors.ts | 7 ++++--- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/utils/__tests__/colors.test.ts b/src/utils/__tests__/colors.test.ts index 85339369..861950df 100644 --- a/src/utils/__tests__/colors.test.ts +++ b/src/utils/__tests__/colors.test.ts @@ -87,4 +87,34 @@ describe('getHeatGaugeColor', () => { expect(color).toBe('hex:FDE047'); // Should use standard model thresholds }); }); + + 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, false, { 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% on standard model = cyan with defaults (< 30%) + const color = getHeatGaugeColor(25, false); + expect(color).toBe('hex:00D9FF'); // Cyan + }); + + it('should apply custom hot threshold correctly', () => { + const color = getHeatGaugeColor(45, false, { 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, false, { 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, false, { 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 23eace66..55492ccb 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 }; @@ -119,9 +120,9 @@ export function bgToFg(colorName: string | undefined): string | undefined { * // [1m] model at 12% usage - shows yellow (pretty hot) * const color = getHeatGaugeColor(12, true); // Returns 'hex:FDE047' */ -export function getHeatGaugeColor(percentage: number, is1MModel = false): string { +export function getHeatGaugeColor(percentage: number, is1MModel = false, customThresholds?: HeatGaugeThresholdSet): string { // Define thresholds based on model type - const thresholds = is1MModel + const thresholds = customThresholds ?? (is1MModel ? { cool: 8, // < 8%: Cool (cyan) warm: 10, // 8-10%: Warm (green/yellow) - "pretty hot" @@ -135,7 +136,7 @@ export function getHeatGaugeColor(percentage: number, is1MModel = false): string 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) { From 78e3c1fd7be22d320d25f027c6cadeffbb5bda8f Mon Sep 17 00:00:00 2001 From: Ethan Stark Date: Wed, 18 Feb 2026 22:08:37 -0500 Subject: [PATCH 10/13] feat: pass custom heat gauge thresholds from settings to color function --- src/widgets/ContextPercentage.ts | 12 +++- .../__tests__/ContextPercentage.test.ts | 56 +++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/src/widgets/ContextPercentage.ts b/src/widgets/ContextPercentage.ts index fa5800af..345c9061 100644 --- a/src/widgets/ContextPercentage.ts +++ b/src/widgets/ContextPercentage.ts @@ -8,7 +8,10 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; -import { getChalkColor, getHeatGaugeColor } from '../utils/colors'; +import { + getChalkColor, + getHeatGaugeColor +} from '../utils/colors'; import { getContextConfig } from '../utils/model-context'; export class ContextPercentageWidget implements Widget { @@ -50,7 +53,7 @@ export class ContextPercentageWidget implements Widget { const previewValue = isInverse ? '90.7%' : '9.3%'; const previewPercentage = isInverse ? 90.7 : 9.3; // For preview, assume standard model (most common case) - const heatColor = getHeatGaugeColor(previewPercentage, false); + const heatColor = getHeatGaugeColor(previewPercentage, false, settings.heatGaugeThresholds?.standard); const chalkColor = getChalkColor(heatColor, 'truecolor'); const coloredValue = chalkColor ? chalkColor(previewValue) : previewValue; return item.rawValue ? coloredValue : `Ctx: ${coloredValue}`; @@ -68,7 +71,10 @@ export class ContextPercentageWidget implements Widget { // Apply heat gauge color based on displayed percentage and model type // Heat gauge colors override widget-level colors to ensure // consistent visual feedback for context usage levels - const heatColor = getHeatGaugeColor(displayPercentage, is1MModel); + const customThresholds = is1MModel + ? settings.heatGaugeThresholds?.extended + : settings.heatGaugeThresholds?.standard; + const heatColor = getHeatGaugeColor(displayPercentage, is1MModel, customThresholds); const chalkColor = getChalkColor(heatColor, 'truecolor'); const coloredPercentage = chalkColor ? chalkColor(percentageString) : percentageString; diff --git a/src/widgets/__tests__/ContextPercentage.test.ts b/src/widgets/__tests__/ContextPercentage.test.ts index 6e37bf15..0895db2b 100644 --- a/src/widgets/__tests__/ContextPercentage.test.ts +++ b/src/widgets/__tests__/ContextPercentage.test.ts @@ -186,4 +186,60 @@ describe('ContextPercentageWidget', () => { expect(stripAnsi(result!)).toBe('Ctx: 21.0%'); }); }); + + describe('Custom heat gauge thresholds from settings', () => { + function renderWithThresholds( + modelId: string | undefined, + contextLength: number, + thresholds: { standard?: { cool: number; warm: number; hot: number; veryHot: number }; extended?: { cool: number; warm: number; hot: number; veryHot: number } } + ) { + 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 standard 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, { standard: { 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 use custom extended thresholds for [1m] models', () => { + // 6% usage on [1m] model with custom cool=5 should produce green + // With defaults, 6% < 8% (cool) would produce cyan + const result = renderWithThresholds('claude-sonnet-4-5-20250929[1m]', 60000, { extended: { cool: 5, warm: 8, hot: 12, veryHot: 18 } }); + 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 thresholds not configured for model type', () => { + // Only extended thresholds set; standard model uses defaults + const result = renderWithThresholds('claude-3-5-sonnet-20241022', 10000, { extended: { cool: 5, warm: 8, hot: 12, veryHot: 18 } }); + expect(result).not.toBeNull(); + // 5% on standard defaults = cyan (< 30%) + // Cyan = rgb(0, 217, 255) = hex:00D9FF + expect(result).toMatch(/\x1b\[38;2;0;217;255m/); + }); + }); }); \ No newline at end of file From 636ca949015bcbdfbf1f37fd48c6a8e161edf5e2 Mon Sep 17 00:00:00 2001 From: Ethan Stark Date: Thu, 19 Feb 2026 09:47:25 -0500 Subject: [PATCH 11/13] style: apply linter fixes --- src/types/Settings.ts | 2 +- src/types/__tests__/Settings.test.ts | 57 ++++++----------------- src/widgets/__tests__/GitWorktree.test.ts | 2 +- 3 files changed, 17 insertions(+), 44 deletions(-) diff --git a/src/types/Settings.ts b/src/types/Settings.ts index 24e72621..4f93183e 100644 --- a/src/types/Settings.ts +++ b/src/types/Settings.ts @@ -28,7 +28,7 @@ const HeatGaugeThresholdSetSchema = z.object({ 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, + t => t.cool < t.warm && t.warm < t.hot && t.hot < t.veryHot, { message: 'Thresholds must be strictly ascending: cool < warm < hot < veryHot' } ); diff --git a/src/types/__tests__/Settings.test.ts b/src/types/__tests__/Settings.test.ts index 20fb7eb5..cc70afc6 100644 --- a/src/types/__tests__/Settings.test.ts +++ b/src/types/__tests__/Settings.test.ts @@ -1,4 +1,9 @@ -import { describe, expect, it } from 'vitest'; +import { + describe, + expect, + it +} from 'vitest'; + import { SettingsSchema } from '../Settings'; describe('SettingsSchema heatGaugeThresholds', () => { @@ -8,20 +13,12 @@ describe('SettingsSchema heatGaugeThresholds', () => { }); it('should accept valid standard thresholds', () => { - const result = SettingsSchema.safeParse({ - heatGaugeThresholds: { - standard: { cool: 20, warm: 35, hot: 50, veryHot: 65 } - } - }); + const result = SettingsSchema.safeParse({ heatGaugeThresholds: { standard: { cool: 20, warm: 35, hot: 50, veryHot: 65 } } }); expect(result.success).toBe(true); }); it('should accept valid extended thresholds', () => { - const result = SettingsSchema.safeParse({ - heatGaugeThresholds: { - extended: { cool: 5, warm: 8, hot: 12, veryHot: 18 } - } - }); + const result = SettingsSchema.safeParse({ heatGaugeThresholds: { extended: { cool: 5, warm: 8, hot: 12, veryHot: 18 } } }); expect(result.success).toBe(true); }); @@ -36,56 +33,32 @@ describe('SettingsSchema heatGaugeThresholds', () => { }); it('should reject thresholds where cool >= warm', () => { - const result = SettingsSchema.safeParse({ - heatGaugeThresholds: { - standard: { cool: 40, warm: 35, hot: 50, veryHot: 65 } - } - }); + const result = SettingsSchema.safeParse({ heatGaugeThresholds: { standard: { 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: { - standard: { cool: 20, warm: 50, hot: 50, veryHot: 65 } - } - }); + const result = SettingsSchema.safeParse({ heatGaugeThresholds: { standard: { 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: { - standard: { cool: 20, warm: 35, hot: 70, veryHot: 65 } - } - }); + const result = SettingsSchema.safeParse({ heatGaugeThresholds: { standard: { cool: 20, warm: 35, hot: 70, veryHot: 65 } } }); expect(result.success).toBe(false); }); it('should reject thresholds below 0', () => { - const result = SettingsSchema.safeParse({ - heatGaugeThresholds: { - standard: { cool: -5, warm: 35, hot: 50, veryHot: 65 } - } - }); + const result = SettingsSchema.safeParse({ heatGaugeThresholds: { standard: { cool: -5, warm: 35, hot: 50, veryHot: 65 } } }); expect(result.success).toBe(false); }); it('should reject thresholds above 100', () => { - const result = SettingsSchema.safeParse({ - heatGaugeThresholds: { - standard: { cool: 20, warm: 35, hot: 50, veryHot: 105 } - } - }); + const result = SettingsSchema.safeParse({ heatGaugeThresholds: { standard: { 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: { - standard: { cool: 20, warm: 35 } - } - }); + const result = SettingsSchema.safeParse({ heatGaugeThresholds: { standard: { cool: 20, warm: 35 } } }); expect(result.success).toBe(false); }); -}); +}); \ 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 From 3df9fce73a431b9195a3a2b45192fc658a66d434 Mon Sep 17 00:00:00 2001 From: Ethan Stark Date: Fri, 20 Feb 2026 09:51:07 -0500 Subject: [PATCH 12/13] feat: add heat gauge colors to ContextPercentageUsable widget - Add heatGaugeColors field to WidgetItemSchema (defaults to true) - Add contextWindow field to RenderContext interface - Port heat gauge color logic, toggle keybind, and editor display from ContextPercentage into ContextPercentageUsable - Support custom thresholds from settings (standard/extended) - Update tests: existing calculation tests use heatGaugeColors:false, new tests verify color on/off behavior --- src/types/RenderContext.ts | 4 ++ src/types/Widget.ts | 1 + src/widgets/ContextPercentageUsable.ts | 62 +++++++++++++++++-- .../__tests__/ContextPercentageUsable.test.ts | 54 +++++++++++++++- 4 files changed, 116 insertions(+), 5 deletions(-) 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/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/widgets/ContextPercentageUsable.ts b/src/widgets/ContextPercentageUsable.ts index 7ab5446c..f51c33a3 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,77 @@ 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, false, settings.heatGaugeThresholds?.standard); + 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 is1MModel = context.contextWindow.contextWindowSize >= 800000; + const customThresholds = is1MModel + ? settings.heatGaugeThresholds?.extended + : settings.heatGaugeThresholds?.standard; + const heatColor = getHeatGaugeColor(displayPercentage, is1MModel, customThresholds); + 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 is1MModel = contextConfig.maxTokens === 1000000; + const customThresholds = is1MModel + ? settings.heatGaugeThresholds?.extended + : settings.heatGaugeThresholds?.standard; + const heatColor = getHeatGaugeColor(displayPercentage, is1MModel, customThresholds); + 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__/ContextPercentageUsable.test.ts b/src/widgets/__tests__/ContextPercentageUsable.test.ts index 60595a07..6a760bc1 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%'); }); }); -}); \ No newline at end of file + + 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%'); + }); + }); +}); From b7d049eb89de7297b41337b382ab76f9d4127b75 Mon Sep 17 00:00:00 2001 From: Ethan Stark Date: Sat, 21 Feb 2026 13:31:30 -0500 Subject: [PATCH 13/13] refactor: remove model-specific heat gauge thresholds All models now use a single default threshold set (cool=30/warm=40/ hot=55/veryHot=70). Removes the compressed 1M-model thresholds that were added when 1M context models were less performant. - Remove `is1MModel` param from `getHeatGaugeColor` - Flatten `settings.heatGaugeThresholds` from `{ standard?, extended? }` to a single optional `HeatGaugeThresholdSet` (power user customisation preserved) - Remove model-detection branching from ContextPercentage and ContextPercentageUsable widgets - Update tests to reflect simplified schema --- src/types/Settings.ts | 5 +- src/types/__tests__/Settings.test.ts | 31 +++----- src/utils/__tests__/colors.test.ts | 72 ++++--------------- src/utils/colors.ts | 59 ++++++--------- src/widgets/ContextPercentage.ts | 13 +--- src/widgets/ContextPercentageUsable.ts | 14 +--- .../__tests__/ContextPercentage.test.ts | 63 ++-------------- .../__tests__/ContextPercentageUsable.test.ts | 2 +- 8 files changed, 57 insertions(+), 202 deletions(-) diff --git a/src/types/Settings.ts b/src/types/Settings.ts index 4f93183e..a39d917f 100644 --- a/src/types/Settings.ts +++ b/src/types/Settings.ts @@ -74,10 +74,7 @@ export const SettingsSchema = z.object({ message: z.string().nullable().optional(), remaining: z.number().nullable().optional() }).optional(), - heatGaugeThresholds: z.object({ - standard: HeatGaugeThresholdSetSchema.optional(), - extended: HeatGaugeThresholdSetSchema.optional() - }).optional() + heatGaugeThresholds: HeatGaugeThresholdSetSchema.optional() }); // Inferred type from schema diff --git a/src/types/__tests__/Settings.test.ts b/src/types/__tests__/Settings.test.ts index cc70afc6..55f705c6 100644 --- a/src/types/__tests__/Settings.test.ts +++ b/src/types/__tests__/Settings.test.ts @@ -12,53 +12,38 @@ describe('SettingsSchema heatGaugeThresholds', () => { expect(result.success).toBe(true); }); - it('should accept valid standard thresholds', () => { - const result = SettingsSchema.safeParse({ heatGaugeThresholds: { standard: { cool: 20, warm: 35, hot: 50, veryHot: 65 } } }); - expect(result.success).toBe(true); - }); - - it('should accept valid extended thresholds', () => { - const result = SettingsSchema.safeParse({ heatGaugeThresholds: { extended: { cool: 5, warm: 8, hot: 12, veryHot: 18 } } }); - expect(result.success).toBe(true); - }); - - it('should accept both standard and extended thresholds', () => { - const result = SettingsSchema.safeParse({ - heatGaugeThresholds: { - standard: { cool: 20, warm: 35, hot: 50, veryHot: 65 }, - extended: { cool: 5, warm: 8, hot: 12, veryHot: 18 } - } - }); + 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: { standard: { cool: 40, warm: 35, hot: 50, veryHot: 65 } } }); + 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: { standard: { cool: 20, warm: 50, hot: 50, veryHot: 65 } } }); + 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: { standard: { cool: 20, warm: 35, hot: 70, veryHot: 65 } } }); + 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: { standard: { cool: -5, warm: 35, hot: 50, veryHot: 65 } } }); + 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: { standard: { cool: 20, warm: 35, hot: 50, veryHot: 105 } } }); + 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: { standard: { cool: 20, warm: 35 } } }); + 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 index 861950df..bf42b84d 100644 --- a/src/utils/__tests__/colors.test.ts +++ b/src/utils/__tests__/colors.test.ts @@ -7,113 +7,69 @@ import { import { getHeatGaugeColor } from '../colors'; describe('getHeatGaugeColor', () => { - describe('Standard models (200k context)', () => { + describe('Default thresholds', () => { it('should return cool cyan for low usage (< 30%)', () => { - const color = getHeatGaugeColor(10, false); + const color = getHeatGaugeColor(10); expect(color).toBe('hex:00D9FF'); // Cyan - cool }); it('should return green for comfortable range (30-40%)', () => { - const color = getHeatGaugeColor(35, false); + const color = getHeatGaugeColor(35); expect(color).toBe('hex:4ADE80'); // Green - comfortable }); it('should return yellow at pretty hot threshold (40%)', () => { - const color = getHeatGaugeColor(40, false); + 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, false); + 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, false); + 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, false); + const color = getHeatGaugeColor(65); expect(color).toBe('hex:FB923C'); // Orange - very hot }); it('should return red for high usage (70%+)', () => { - const color = getHeatGaugeColor(70, false); + const color = getHeatGaugeColor(70); expect(color).toBe('hex:F87171'); // Red - critical }); }); - describe('[1m] models (1M context)', () => { - it('should return cool cyan for low usage (< 8%)', () => { - const color = getHeatGaugeColor(5, true); - expect(color).toBe('hex:00D9FF'); // Cyan - cool - }); - - it('should return green for comfortable range (8-10%)', () => { - const color = getHeatGaugeColor(9, true); - expect(color).toBe('hex:4ADE80'); // Green - comfortable - }); - - it('should return yellow at pretty hot threshold (10%)', () => { - const color = getHeatGaugeColor(10, true); - expect(color).toBe('hex:FDE047'); // Yellow - pretty hot - }); - - it('should return yellow in pretty hot range (10-15%)', () => { - const color = getHeatGaugeColor(12, true); - expect(color).toBe('hex:FDE047'); // Yellow - pretty hot - }); - - it('should return orange at very hot threshold (15%)', () => { - const color = getHeatGaugeColor(15, true); - expect(color).toBe('hex:FB923C'); // Orange - very hot - }); - - it('should return orange in very hot range (15-20%)', () => { - const color = getHeatGaugeColor(18, true); - expect(color).toBe('hex:FB923C'); // Orange - very hot - }); - - it('should return red for high usage (20%+)', () => { - const color = getHeatGaugeColor(20, true); - expect(color).toBe('hex:F87171'); // Red - critical - }); - }); - - describe('Backward compatibility', () => { - it('should use standard model thresholds when is1MModel is undefined', () => { - const color = getHeatGaugeColor(40); - expect(color).toBe('hex:FDE047'); // Should use standard model thresholds - }); - }); - 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, false, { cool: 20, warm: 35, hot: 50, veryHot: 65 }); + 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% on standard model = cyan with defaults (< 30%) - const color = getHeatGaugeColor(25, false); + // 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, false, { cool: 20, warm: 35, hot: 50, veryHot: 65 }); + 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, false, { cool: 20, warm: 35, hot: 50, veryHot: 65 }); + 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, false, { cool: 20, warm: 35, hot: 50, veryHot: 65 }); + const color = getHeatGaugeColor(70, { cool: 20, warm: 35, hot: 50, veryHot: 65 }); expect(color).toBe('hex:F87171'); // Red — 70 >= veryHot=65 }); }); diff --git a/src/utils/colors.ts b/src/utils/colors.ts index 55492ccb..1e9fdf7d 100644 --- a/src/utils/colors.ts +++ b/src/utils/colors.ts @@ -84,59 +84,42 @@ export function bgToFg(colorName: string | undefined): string | undefined { } /** - * Maps a percentage value (0-100) to a heat gauge color based on model type. + * Maps a percentage value (0-100) to a heat gauge color. * - * Uses different thresholds for [1m] models vs standard models: - * - **Standard models (200k context)**: Conservative thresholds - * - < 30%: Cool cyan - plenty of space - * - 30-40%: Comfortable green - * - 40-55%: "Pretty hot" yellow - getting warm (40% threshold) - * - 55-70%: "Very hot" orange - concerning (55% threshold) - * - 70%+: Critical red - take action - * - * - **[1m] models (1M context)**: Very conservative thresholds - * - < 8%: Cool cyan - plenty of space - * - 8-10%: Comfortable green - * - 10-15%: "Pretty hot" yellow - getting warm (10% threshold) - * - 15-20%: "Very hot" orange - concerning (15% threshold) - * - 20%+: Critical red - take action + * 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 widget to provide visual feedback. + * **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 is1MModel - Whether this is a [1m] model with 1M context (default: false) + * @param customThresholds - Optional custom thresholds that override defaults * @returns A color name string in hex:XXXXXX format, compatible with getChalkColor * * @example - * // Standard model at 45% usage - shows yellow (pretty hot) - * const color = getHeatGaugeColor(45, false); // Returns 'hex:FDE047' + * // 45% usage - shows yellow (pretty hot) + * const color = getHeatGaugeColor(45); // Returns 'hex:FDE047' * * @example - * // [1m] model at 12% usage - shows yellow (pretty hot) - * const color = getHeatGaugeColor(12, true); // Returns 'hex:FDE047' + * // Custom thresholds + * const color = getHeatGaugeColor(25, { cool: 20, warm: 35, hot: 50, veryHot: 65 }); // Returns 'hex:4ADE80' */ -export function getHeatGaugeColor(percentage: number, is1MModel = false, customThresholds?: HeatGaugeThresholdSet): string { - // Define thresholds based on model type - const thresholds = customThresholds ?? (is1MModel - ? { - cool: 8, // < 8%: Cool (cyan) - warm: 10, // 8-10%: Warm (green/yellow) - "pretty hot" - hot: 15, // 10-15%: Hot (orange) - "very hot" - veryHot: 20 // 15-20%: Very hot (orange-red) - // 20%+: Critical (red) - } - : { - 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) - }); +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) { diff --git a/src/widgets/ContextPercentage.ts b/src/widgets/ContextPercentage.ts index 345c9061..9a20c37a 100644 --- a/src/widgets/ContextPercentage.ts +++ b/src/widgets/ContextPercentage.ts @@ -52,8 +52,7 @@ export class ContextPercentageWidget implements Widget { if (context.isPreview) { const previewValue = isInverse ? '90.7%' : '9.3%'; const previewPercentage = isInverse ? 90.7 : 9.3; - // For preview, assume standard model (most common case) - const heatColor = getHeatGaugeColor(previewPercentage, false, settings.heatGaugeThresholds?.standard); + const heatColor = getHeatGaugeColor(previewPercentage, settings.heatGaugeThresholds); const chalkColor = getChalkColor(heatColor, 'truecolor'); const coloredValue = chalkColor ? chalkColor(previewValue) : previewValue; return item.rawValue ? coloredValue : `Ctx: ${coloredValue}`; @@ -65,16 +64,10 @@ export class ContextPercentageWidget implements Widget { const displayPercentage = isInverse ? (100 - usedPercentage) : usedPercentage; const percentageString = `${displayPercentage.toFixed(1)}%`; - // Determine if this is a [1m] model for heat gauge thresholds - const is1MModel = contextConfig.maxTokens === 1000000; - - // Apply heat gauge color based on displayed percentage and model type + // 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 customThresholds = is1MModel - ? settings.heatGaugeThresholds?.extended - : settings.heatGaugeThresholds?.standard; - const heatColor = getHeatGaugeColor(displayPercentage, is1MModel, customThresholds); + const heatColor = getHeatGaugeColor(displayPercentage, settings.heatGaugeThresholds); const chalkColor = getChalkColor(heatColor, 'truecolor'); const coloredPercentage = chalkColor ? chalkColor(percentageString) : percentageString; diff --git a/src/widgets/ContextPercentageUsable.ts b/src/widgets/ContextPercentageUsable.ts index f51c33a3..1581a7d9 100644 --- a/src/widgets/ContextPercentageUsable.ts +++ b/src/widgets/ContextPercentageUsable.ts @@ -57,7 +57,7 @@ export class ContextPercentageUsableWidget implements Widget { const previewValue = isInverse ? '88.4%' : '11.6%'; const previewPercentage = isInverse ? 88.4 : 11.6; if (useHeatGauge) { - const heatColor = getHeatGaugeColor(previewPercentage, false, settings.heatGaugeThresholds?.standard); + const heatColor = getHeatGaugeColor(previewPercentage, settings.heatGaugeThresholds); const chalkColor = getChalkColor(heatColor, 'truecolor'); const coloredValue = chalkColor ? chalkColor(previewValue) : previewValue; return item.rawValue ? coloredValue : `Ctx(u): ${coloredValue}`; @@ -73,11 +73,7 @@ export class ContextPercentageUsableWidget implements Widget { const percentageString = `${displayPercentage.toFixed(1)}%`; if (useHeatGauge) { - const is1MModel = context.contextWindow.contextWindowSize >= 800000; - const customThresholds = is1MModel - ? settings.heatGaugeThresholds?.extended - : settings.heatGaugeThresholds?.standard; - const heatColor = getHeatGaugeColor(displayPercentage, is1MModel, customThresholds); + const heatColor = getHeatGaugeColor(displayPercentage, settings.heatGaugeThresholds); const chalkColor = getChalkColor(heatColor, 'truecolor'); const coloredPercentage = chalkColor ? chalkColor(percentageString) : percentageString; return item.rawValue ? coloredPercentage : `Ctx(u): ${coloredPercentage}`; @@ -95,11 +91,7 @@ export class ContextPercentageUsableWidget implements Widget { const percentageString = `${displayPercentage.toFixed(1)}%`; if (useHeatGauge) { - const is1MModel = contextConfig.maxTokens === 1000000; - const customThresholds = is1MModel - ? settings.heatGaugeThresholds?.extended - : settings.heatGaugeThresholds?.standard; - const heatColor = getHeatGaugeColor(displayPercentage, is1MModel, customThresholds); + const heatColor = getHeatGaugeColor(displayPercentage, settings.heatGaugeThresholds); const chalkColor = getChalkColor(heatColor, 'truecolor'); const coloredPercentage = chalkColor ? chalkColor(percentageString) : percentageString; return item.rawValue ? coloredPercentage : `Ctx(u): ${coloredPercentage}`; diff --git a/src/widgets/__tests__/ContextPercentage.test.ts b/src/widgets/__tests__/ContextPercentage.test.ts index 0895db2b..bd49d63f 100644 --- a/src/widgets/__tests__/ContextPercentage.test.ts +++ b/src/widgets/__tests__/ContextPercentage.test.ts @@ -107,47 +107,6 @@ describe('ContextPercentageWidget', () => { }); }); - describe('Model-aware heat gauge colors', () => { - it('should use [1m] model thresholds for Sonnet 4.5 with [1m] suffix', () => { - // 10% usage on [1m] model should trigger "pretty hot" color (yellow) - const result = render('claude-sonnet-4-5-20250929[1m]', 100000); // 10% of 1M - expect(result).toContain('10.0%'); - // Verify yellow color code is present (FDE047 = rgb(253, 224, 71)) - expect(result).toMatch(/\x1b\[38;2;253;224;71m/); // RGB for FDE047 - }); - - it('should use standard model thresholds for older models', () => { - // 10% usage on standard model should still be cool (cyan) - const result = render('claude-3-5-sonnet-20241022', 20000); // 10% of 200k - expect(result).toContain('10.0%'); - // Verify cyan color code is present (00D9FF = rgb(0, 217, 255)) - expect(result).toMatch(/\x1b\[38;2;0;217;255m/); // RGB for 00D9FF - }); - - it('should differentiate pretty hot thresholds between model types', () => { - // 40% on standard model = yellow (pretty hot) - const standardResult = render('claude-3-5-sonnet-20241022', 80000); // 40% of 200k - expect(standardResult).not.toBeNull(); - expect(stripAnsi(standardResult!)).toContain('40.0%'); - - // 40% on [1m] model = red (critical) - const model1MResult = render('claude-sonnet-4-5-20250929[1m]', 400000); // 40% of 1M - expect(model1MResult).not.toBeNull(); - expect(stripAnsi(model1MResult!)).toContain('40.0%'); - - // Colors should be very different - expect(standardResult).not.toBe(model1MResult); - }); - - it('should handle inverse mode with model-aware colors', () => { - // [1m] model: 85% used = 15% remaining (should be warm/hot threshold) - const result = render('claude-sonnet-4-5-20250929[1m]', 850000, false, true); - expect(result).toContain('15.0%'); - // Should show orange color (15% threshold for [1m] models) - expect(result).toMatch(/\x1b\[38;2;251;146;60m/); // RGB for FB923C - }); - }); - 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); @@ -191,7 +150,7 @@ describe('ContextPercentageWidget', () => { function renderWithThresholds( modelId: string | undefined, contextLength: number, - thresholds: { standard?: { cool: number; warm: number; hot: number; veryHot: number }; extended?: { cool: number; warm: number; hot: number; veryHot: number } } + thresholds: { cool: number; warm: number; hot: number; veryHot: number } | undefined ) { const widget = new ContextPercentageWidget(); const context: RenderContext = { @@ -215,29 +174,19 @@ describe('ContextPercentageWidget', () => { return widget.render(item, context, settings); } - it('should use custom standard thresholds when configured', () => { + 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, { standard: { 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 use custom extended thresholds for [1m] models', () => { - // 6% usage on [1m] model with custom cool=5 should produce green - // With defaults, 6% < 8% (cool) would produce cyan - const result = renderWithThresholds('claude-sonnet-4-5-20250929[1m]', 60000, { extended: { cool: 5, warm: 8, hot: 12, veryHot: 18 } }); + 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 thresholds not configured for model type', () => { - // Only extended thresholds set; standard model uses defaults - const result = renderWithThresholds('claude-3-5-sonnet-20241022', 10000, { extended: { cool: 5, warm: 8, hot: 12, veryHot: 18 } }); + 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(); - // 5% on standard defaults = cyan (< 30%) // Cyan = rgb(0, 217, 255) = hex:00D9FF expect(result).toMatch(/\x1b\[38;2;0;217;255m/); }); diff --git a/src/widgets/__tests__/ContextPercentageUsable.test.ts b/src/widgets/__tests__/ContextPercentageUsable.test.ts index 6a760bc1..948ccc46 100644 --- a/src/widgets/__tests__/ContextPercentageUsable.test.ts +++ b/src/widgets/__tests__/ContextPercentageUsable.test.ts @@ -114,4 +114,4 @@ describe('ContextPercentageUsableWidget', () => { expect(result).toBe('Ctx(u): 6.3%'); }); }); -}); +}); \ No newline at end of file