Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/types/RenderContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
15 changes: 14 additions & 1 deletion src/types/Settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof HeatGaugeThresholdSetSchema>;

// Main settings schema with defaults
export const SettingsSchema = z.object({
version: z.number().default(CURRENT_VERSION),
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/types/Widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
});

Expand Down
49 changes: 49 additions & 0 deletions src/types/__tests__/Settings.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
76 changes: 76 additions & 0 deletions src/utils/__tests__/colors.test.ts
Original file line number Diff line number Diff line change
@@ -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
});
});
});
53 changes: 53 additions & 0 deletions src/utils/colors.ts
Original file line number Diff line number Diff line change
@@ -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 };
Expand Down Expand Up @@ -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;
Expand Down
23 changes: 21 additions & 2 deletions src/widgets/ContextPercentage.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import chalk from 'chalk';

import type { RenderContext } from '../types/RenderContext';
import type { Settings } from '../types/Settings';
import type {
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
}
Expand Down
54 changes: 50 additions & 4 deletions src/widgets/ContextPercentageUsable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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(', ')})`
};
}

Expand All @@ -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' }
];
}

Expand Down
Loading