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
1 change: 1 addition & 0 deletions src/types/Widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export interface Widget {
getDisplayName(): string;
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay;
render(item: WidgetItem, context: RenderContext, settings: Settings): string | null;
getEffectiveColor?(item: WidgetItem, context: RenderContext, settings: Settings): string | undefined;
getCustomKeybinds?(): CustomKeybind[];
renderEditor?(props: WidgetEditorProps): React.ReactElement | null;
supportsRawValue(): boolean;
Expand Down
135 changes: 135 additions & 0 deletions src/utils/__tests__/color-thresholds.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import {
describe,
expect,
it
} from 'vitest';

import type { WidgetItem } from '../../types/Widget';
import {
THRESHOLD_CYCLE_ORDER,
getCurrentPreset,
getContextThresholdColor,
getPresetLabel
} from '../color-thresholds';

function makeItem(metadata?: Record<string, string>): WidgetItem {
return {
id: 'test',
type: 'context-percentage',
metadata
};
}

describe('getContextThresholdColor', () => {
describe('default preset (50/75%)', () => {
it('should return undefined below warning threshold', () => {
expect(getContextThresholdColor(makeItem(), 0)).toBeUndefined();
expect(getContextThresholdColor(makeItem(), 25)).toBeUndefined();
expect(getContextThresholdColor(makeItem(), 49.9)).toBeUndefined();
});

it('should return yellow at warning threshold', () => {
expect(getContextThresholdColor(makeItem(), 50)).toBe('yellow');
});

it('should return yellow between warning and critical', () => {
expect(getContextThresholdColor(makeItem(), 60)).toBe('yellow');
expect(getContextThresholdColor(makeItem(), 74.9)).toBe('yellow');
});

it('should return red at critical threshold', () => {
expect(getContextThresholdColor(makeItem(), 75)).toBe('red');
});

it('should return red above critical threshold', () => {
expect(getContextThresholdColor(makeItem(), 90)).toBe('red');
expect(getContextThresholdColor(makeItem(), 100)).toBe('red');
});
});

describe('conservative preset (30/60%)', () => {
const item = makeItem({ thresholdPreset: 'conservative' });

it('should return undefined below 30%', () => {
expect(getContextThresholdColor(item, 29.9)).toBeUndefined();
});

it('should return yellow at 30%', () => {
expect(getContextThresholdColor(item, 30)).toBe('yellow');
});

it('should return red at 60%', () => {
expect(getContextThresholdColor(item, 60)).toBe('red');
});
});

describe('aggressive preset (70/90%)', () => {
const item = makeItem({ thresholdPreset: 'aggressive' });

it('should return undefined below 70%', () => {
expect(getContextThresholdColor(item, 69.9)).toBeUndefined();
});

it('should return yellow at 70%', () => {
expect(getContextThresholdColor(item, 70)).toBe('yellow');
});

it('should return red at 90%', () => {
expect(getContextThresholdColor(item, 90)).toBe('red');
});
});

describe('off preset', () => {
it('should return undefined regardless of percentage', () => {
const item = makeItem({ colorThresholds: 'false' });
expect(getContextThresholdColor(item, 0)).toBeUndefined();
expect(getContextThresholdColor(item, 50)).toBeUndefined();
expect(getContextThresholdColor(item, 100)).toBeUndefined();
});
});

describe('edge cases', () => {
it('should handle 0% usage', () => {
expect(getContextThresholdColor(makeItem(), 0)).toBeUndefined();
});

it('should handle 100% usage', () => {
expect(getContextThresholdColor(makeItem(), 100)).toBe('red');
});

it('should handle no metadata', () => {
const item = makeItem(undefined);
expect(getContextThresholdColor(item, 60)).toBe('yellow');
});
});
});

describe('getCurrentPreset', () => {
it('should return default when no metadata', () => {
expect(getCurrentPreset(makeItem())).toBe('default');
});

it('should return the configured preset', () => {
expect(getCurrentPreset(makeItem({ thresholdPreset: 'conservative' }))).toBe('conservative');
expect(getCurrentPreset(makeItem({ thresholdPreset: 'aggressive' }))).toBe('aggressive');
});

it('should return off when colorThresholds is false', () => {
expect(getCurrentPreset(makeItem({ colorThresholds: 'false' }))).toBe('off');
});
});

describe('getPresetLabel', () => {
it('should return correct labels for all presets', () => {
expect(getPresetLabel('default')).toBe('thresholds: 50/75%');
expect(getPresetLabel('conservative')).toBe('thresholds: 30/60%');
expect(getPresetLabel('aggressive')).toBe('thresholds: 70/90%');
expect(getPresetLabel('off')).toBe('thresholds: off');
});
});

describe('THRESHOLD_CYCLE_ORDER', () => {
it('should cycle through all four presets', () => {
expect(THRESHOLD_CYCLE_ORDER).toEqual(['default', 'conservative', 'aggressive', 'off']);
});
});
55 changes: 55 additions & 0 deletions src/utils/color-thresholds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { WidgetItem } from '../types/Widget';

export type ThresholdPreset = 'default' | 'conservative' | 'aggressive' | 'off';

export interface ThresholdConfig {
warn: number;
crit: number;
warnColor: string;
critColor: string;
}

export const THRESHOLD_PRESETS: Record<ThresholdPreset, ThresholdConfig | null> = {
default: { warn: 50, crit: 75, warnColor: 'yellow', critColor: 'red' },
conservative: { warn: 30, crit: 60, warnColor: 'yellow', critColor: 'red' },
aggressive: { warn: 70, crit: 90, warnColor: 'yellow', critColor: 'red' },
off: null
};

export const THRESHOLD_CYCLE_ORDER: ThresholdPreset[] = ['default', 'conservative', 'aggressive', 'off'];

/**
* Get the effective color for a context percentage widget based on usage thresholds.
* Uses the USAGE percentage (how full context is), regardless of inverse display mode.
*/
export function getContextThresholdColor(item: WidgetItem, usagePercentage: number): string | undefined {
if (item.metadata?.colorThresholds === 'false') return undefined;

const preset = (item.metadata?.thresholdPreset as ThresholdPreset) ?? 'default';
const config = THRESHOLD_PRESETS[preset];
if (!config) return undefined;

if (usagePercentage >= config.crit) return config.critColor;
if (usagePercentage >= config.warn) return config.warnColor;
return undefined;
}

/**
* Get the current threshold preset from widget metadata.
*/
export function getCurrentPreset(item: WidgetItem): ThresholdPreset {
if (item.metadata?.colorThresholds === 'false') return 'off';
return (item.metadata?.thresholdPreset as ThresholdPreset) ?? 'default';
}

/**
* Get display label for a threshold preset.
*/
export function getPresetLabel(preset: ThresholdPreset): string {
switch (preset) {
case 'default': return 'thresholds: 50/75%';
case 'conservative': return 'thresholds: 30/60%';
case 'aggressive': return 'thresholds: 70/90%';
case 'off': return 'thresholds: off';
}
}
11 changes: 8 additions & 3 deletions src/utils/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,10 @@ function renderPowerlineStatusLine(
const trailingPadding = omitTrailingPadding ? '' : padding;
const paddedText = `${leadingPadding}${widgetText}${trailingPadding}`;

// Determine colors
let fgColor = widget.color ?? defaultColor;
// Determine colors - check for dynamic effective color (e.g., threshold-based)
const widgetImplForColor = getWidget(widget.type);
const effectiveColor = widgetImplForColor?.getEffectiveColor?.(widget, context, settings);
let fgColor = effectiveColor ?? widget.color ?? defaultColor;
let bgColor = widget.backgroundColor;

// Apply theme colors if a theme is set (and not 'custom')
Expand Down Expand Up @@ -811,8 +813,11 @@ export function renderStatusLine(
elements.push({ content: finalOutput, type: widget.type, widget });
} else {
// Normal widget rendering with colors
// Check if widget has dynamic effective color (e.g., threshold-based)
const widgetImpl = getWidget(widget.type);
const effectiveColor = widgetImpl?.getEffectiveColor?.(widget, context, settings);
elements.push({
content: applyColorsWithOverride(widgetText, widget.color ?? defaultColor, widget.backgroundColor, widget.bold),
content: applyColorsWithOverride(widgetText, effectiveColor ?? widget.color ?? defaultColor, widget.backgroundColor, widget.bold),
type: widget.type,
widget
});
Expand Down
41 changes: 40 additions & 1 deletion src/widgets/ContextPercentage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import type {
WidgetEditorDisplay,
WidgetItem
} from '../types/Widget';
import {
THRESHOLD_CYCLE_ORDER,
getCurrentPreset,
getContextThresholdColor,
getPresetLabel
} from '../utils/color-thresholds';
import { getContextConfig } from '../utils/model-context';

export class ContextPercentageWidget implements Widget {
Expand All @@ -20,6 +26,9 @@ export class ContextPercentageWidget implements Widget {
modifiers.push('remaining');
}

const preset = getCurrentPreset(item);
modifiers.push(getPresetLabel(preset));

return {
displayText: this.getDisplayName(),
modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined
Expand All @@ -37,6 +46,23 @@ export class ContextPercentageWidget implements Widget {
}
};
}
if (action === 'cycle-thresholds') {
const current = getCurrentPreset(item);
const currentIndex = THRESHOLD_CYCLE_ORDER.indexOf(current);
const nextPreset = THRESHOLD_CYCLE_ORDER[(currentIndex + 1) % THRESHOLD_CYCLE_ORDER.length] ?? 'default';
const { colorThresholds, thresholdPreset, ...restMetadata } = item.metadata ?? {};
void colorThresholds; void thresholdPreset;
if (nextPreset === 'off') {
return {
...item,
metadata: { ...restMetadata, colorThresholds: 'false' }
};
}
return {
...item,
metadata: { ...restMetadata, thresholdPreset: nextPreset }
};
}
return null;
}

Expand All @@ -57,9 +83,22 @@ export class ContextPercentageWidget implements Widget {
return null;
}

getEffectiveColor(item: WidgetItem, context: RenderContext, settings: Settings): string | undefined {
if (context.isPreview) return undefined;
if (!context.tokenMetrics) return undefined;

const model = context.data?.model;
const modelId = typeof model === 'string' ? model : model?.id;
const contextConfig = getContextConfig(modelId);
const usedPercentage = Math.min(100, (context.tokenMetrics.contextLength / contextConfig.maxTokens) * 100);

return getContextThresholdColor(item, usedPercentage);
}

getCustomKeybinds(): CustomKeybind[] {
return [
{ key: 'l', label: '(l)eft/remaining', action: 'toggle-inverse' }
{ key: 'l', label: '(l)eft/remaining', action: 'toggle-inverse' },
{ key: 't', label: '(t)hresholds', action: 'cycle-thresholds' }
];
}

Expand Down
42 changes: 41 additions & 1 deletion src/widgets/ContextPercentageUsable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import type {
WidgetEditorDisplay,
WidgetItem
} from '../types/Widget';
import {
THRESHOLD_CYCLE_ORDER,
getCurrentPreset,
getContextThresholdColor,
getPresetLabel
} from '../utils/color-thresholds';
import { getContextConfig } from '../utils/model-context';

export class ContextPercentageUsableWidget implements Widget {
Expand All @@ -20,6 +26,9 @@ export class ContextPercentageUsableWidget implements Widget {
modifiers.push('remaining');
}

const preset = getCurrentPreset(item);
modifiers.push(getPresetLabel(preset));

return {
displayText: this.getDisplayName(),
modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined
Expand All @@ -37,6 +46,23 @@ export class ContextPercentageUsableWidget implements Widget {
}
};
}
if (action === 'cycle-thresholds') {
const current = getCurrentPreset(item);
const currentIndex = THRESHOLD_CYCLE_ORDER.indexOf(current);
const nextPreset = THRESHOLD_CYCLE_ORDER[(currentIndex + 1) % THRESHOLD_CYCLE_ORDER.length] ?? 'default';
const { colorThresholds, thresholdPreset, ...restMetadata } = item.metadata ?? {};
void colorThresholds; void thresholdPreset;
if (nextPreset === 'off') {
return {
...item,
metadata: { ...restMetadata, colorThresholds: 'false' }
};
}
return {
...item,
metadata: { ...restMetadata, thresholdPreset: nextPreset }
};
}
return null;
}

Expand All @@ -57,9 +83,23 @@ export class ContextPercentageUsableWidget implements Widget {
return null;
}

getEffectiveColor(item: WidgetItem, context: RenderContext, settings: Settings): string | undefined {
if (context.isPreview) return undefined;
if (!context.tokenMetrics) return undefined;

const model = context.data?.model;
const modelId = typeof model === 'string' ? model : model?.id;
const contextConfig = getContextConfig(modelId);
// Use usableTokens for the usable variant
const usedPercentage = Math.min(100, (context.tokenMetrics.contextLength / contextConfig.usableTokens) * 100);

return getContextThresholdColor(item, usedPercentage);
}

getCustomKeybinds(): CustomKeybind[] {
return [
{ key: 'l', label: '(l)eft/remaining', action: 'toggle-inverse' }
{ key: 'l', label: '(l)eft/remaining', action: 'toggle-inverse' },
{ key: 't', label: '(t)hresholds', action: 'cycle-thresholds' }
];
}

Expand Down
Loading