fix(context): support 1M model name formats in fallback parsing

Handle [1M], (1M), and optional context labels when inferring model context size; use model id + display_name in context widgets and add regression coverage for ctx, ctx(u), and context bar.

Closes #193
This commit is contained in:
Matthew Breedlove committed 2026-03-03 21:49:49 -05:00
1 parent b90840f68c
commit 7e2ad30f77
12 files changed
+260 -32

No files matched your search

+4 -3
View File
@@ -46,7 +46,7 @@
## 🆕 Recent Updates
### v2.1.0 - v2.1.4 - Usage widgets, links, new git insertions / deletions widgets, and reliability fixes
### v2.1.0 - v2.1.6 - Usage widgets, links, new git insertions / deletions widgets, and reliability fixes
- **🧩 New Usage widgets (v2.1.0)** - Added **Session Usage**, **Weekly Usage**, **Reset Timer**, and **Context Bar** widgets.
- **📊 More accurate counts (v2.1.0)** - Usage/context widgets now use new statusline JSON metrics when available for more accurate token and context counts.
@@ -54,6 +54,7 @@
- **🔗 New Link widget (v2.1.3)** - Added a new **Link** widget with clickable OSC8 rendering, preview parity, and raw mode support.
- **➕ New Git Insertions widget (v2.1.4)** - Added a dedicated Git widget that shows only uncommitted insertions (e.g., `+42`).
- **➖ New Git Deletions widget (v2.1.4)** - Added a dedicated Git widget that shows only uncommitted deletions (e.g., `-10`).
- **🧠 Context format fallback fix (v2.1.6)** - When `context_window_size` is missing, context widgets now infer 1M models from long-context labels such as `[1m]` and `1M context` in model identifiers.
### v2.0.26 - v2.0.29 - Performance, git internals, and workflow improvements
@@ -434,8 +435,8 @@ bun run example
- **Tokens Cached** - Shows cached tokens used
- **Tokens Total** - Shows total tokens used
- **Context Length** - Shows current context length in tokens
- **Context Percentage** - Shows percentage of context limit used (dynamic: 1M for model IDs with `[1m]` suffix, 200k otherwise)
- **Context Percentage (usable)** - Shows percentage of usable context (dynamic: 800k for model IDs with `[1m]` suffix, 160k otherwise, accounting for auto-compact at 80%)
- **Context Percentage** - Shows percentage of context limit used (dynamic: 1M for model IDs with long-context labels like `[1m]` or `1M context`, 200k otherwise)
- **Context Percentage (usable)** - Shows percentage of usable context (dynamic: 800k for model IDs with long-context labels like `[1m]` or `1M context`, 160k otherwise, accounting for auto-compact at 80%)
- **Context Bar** - Shows context usage as a progress bar with short/full display modes
- **Terminal Width** - Shows detected terminal width (for debugging)
- **Memory Usage** - Shows system memory usage (used/total, e.g., "Mem: 12.4G/16.0G")
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ccstatusline",
"version": "2.1.5",
"version": "2.1.6",
"description": "A customizable status line formatter for Claude Code CLI",
"module": "src/ccstatusline.ts",
"type": "module",
@@ -102,6 +102,59 @@ describe('calculateContextPercentage', () => {
const percentage = calculateContextPercentage(context);
expect(percentage).toBe(100);
});
it('should calculate percentage using 1M denominator with 1M context label', () => {
const context: RenderContext = {
data: { model: { id: 'Opus 4.6 (1M context)' } },
tokenMetrics: {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
totalTokens: 0,
contextLength: 42000
}
};
const percentage = calculateContextPercentage(context);
expect(percentage).toBe(4.2);
});
it('should calculate percentage using 1M denominator with 1M in parentheses', () => {
const context: RenderContext = {
data: { model: { id: 'Opus 4.6 (1M)' } },
tokenMetrics: {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
totalTokens: 0,
contextLength: 42000
}
};
const percentage = calculateContextPercentage(context);
expect(percentage).toBe(4.2);
});
it('should calculate percentage from display_name when model id lacks context size suffix', () => {
const context: RenderContext = {
data: {
model: {
id: 'claude-opus-4-6',
display_name: 'Opus 4.6 (1M context)'
}
},
tokenMetrics: {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
totalTokens: 0,
contextLength: 42000
}
};
const percentage = calculateContextPercentage(context);
expect(percentage).toBe(4.2);
});
});
describe('Older models with 200k context window', () => {
+54 -1
View File
@@ -4,7 +4,10 @@ import {
it
} from 'vitest';
import { getContextConfig } from '../model-context';
import {
getContextConfig,
getModelContextIdentifier
} from '../model-context';
describe('getContextConfig', () => {
describe('Status JSON context window size override', () => {
@@ -53,6 +56,34 @@ describe('getContextConfig', () => {
expect(config.maxTokens).toBe(1000000);
expect(config.usableTokens).toBe(800000);
});
it('should return 1M context window for model IDs with 1M context label', () => {
const config = getContextConfig('Opus 4.6 (1M context)');
expect(config.maxTokens).toBe(1000000);
expect(config.usableTokens).toBe(800000);
});
it('should return 1M context window for model IDs with 1M token context label', () => {
const config = getContextConfig('Claude Opus 4.6 - 1M token context');
expect(config.maxTokens).toBe(1000000);
expect(config.usableTokens).toBe(800000);
});
it('should return 1M context window for model IDs with 1M in parentheses', () => {
const config = getContextConfig('Opus 4.6 (1M)');
expect(config.maxTokens).toBe(1000000);
expect(config.usableTokens).toBe(800000);
});
it('should return 1M context window for model IDs with 1M in square brackets', () => {
const config = getContextConfig('Opus 4.5 [1M]');
expect(config.maxTokens).toBe(1000000);
expect(config.usableTokens).toBe(800000);
});
});
describe('Models without [1m] suffix', () => {
@@ -95,4 +126,26 @@ describe('getContextConfig', () => {
expect(config.usableTokens).toBe(160000);
});
});
});
describe('getModelContextIdentifier', () => {
it('returns string model identifier unchanged', () => {
expect(getModelContextIdentifier('claude-sonnet-4-5-20250929[1m]')).toBe('claude-sonnet-4-5-20250929[1m]');
});
it('prefers both id and display name when available', () => {
expect(getModelContextIdentifier({
id: 'claude-opus-4-6',
display_name: 'Opus 4.6 (1M context)'
})).toBe('claude-opus-4-6 Opus 4.6 (1M context)');
});
it('returns display name when id is missing', () => {
expect(getModelContextIdentifier({ display_name: 'Opus 4.6 (1M context)' })).toBe('Opus 4.6 (1M context)');
});
it('returns undefined when no model value exists', () => {
expect(getModelContextIdentifier(undefined)).toBeUndefined();
expect(getModelContextIdentifier({})).toBeUndefined();
});
});
+6 -4
View File
@@ -1,7 +1,10 @@
import type { RenderContext } from '../types';
import { getContextWindowMetrics } from './context-window';
import { getContextConfig } from './model-context';
import {
getContextConfig,
getModelContextIdentifier
} from './model-context';
/**
* Calculate context window usage percentage based on model's max tokens
@@ -16,9 +19,8 @@ export function calculateContextPercentage(context: RenderContext): number {
return 0;
}
const model = context.data?.model;
const modelId = typeof model === 'string' ? model : model?.id;
const contextConfig = getContextConfig(modelId, contextWindowMetrics.windowSize);
const modelIdentifier = getModelContextIdentifier(context.data?.model);
const contextConfig = getContextConfig(modelIdentifier, contextWindowMetrics.windowSize);
return Math.min(100, (context.tokenMetrics.contextLength / contextConfig.maxTokens) * 100);
}
+72 -11
View File
@@ -3,6 +3,14 @@ interface ModelContextConfig {
usableTokens: number;
}
interface ModelIdentifier {
id?: string;
display_name?: string;
}
const DEFAULT_CONTEXT_WINDOW_SIZE = 200000;
const USABLE_CONTEXT_RATIO = 0.8;
function toValidWindowSize(value: number | null | undefined): number | null {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
return null;
@@ -11,33 +19,86 @@ function toValidWindowSize(value: number | null | undefined): number | null {
return value;
}
export function getContextConfig(modelId?: string, contextWindowSize?: number | null): ModelContextConfig {
function parseContextWindowSize(modelIdentifier: string): number | null {
const delimitedMatch = /(?:\(|\[)\s*(\d+(?:[,_]\d+)*(?:\.\d+)?)\s*([km])\s*(?:\)|\])/i.exec(modelIdentifier);
if (delimitedMatch) {
const delimitedValue = delimitedMatch[1];
const delimitedUnit = delimitedMatch[2];
if (!delimitedValue || !delimitedUnit) {
return null;
}
const parsed = Number.parseFloat(delimitedValue.replace(/[,_]/g, ''));
if (Number.isFinite(parsed) && parsed > 0) {
return Math.round(parsed * (delimitedUnit.toLowerCase() === 'm' ? 1000000 : 1000));
}
}
const contextMatch = /\b(\d+(?:[,_]\d+)*(?:\.\d+)?)\s*([km])(?:\s*(?:token\s*)?context)?\b/i.exec(modelIdentifier);
if (!contextMatch) {
return null;
}
const contextValue = contextMatch[1];
const contextUnit = contextMatch[2];
if (!contextValue || !contextUnit) {
return null;
}
const parsed = Number.parseFloat(contextValue.replace(/[,_]/g, ''));
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.round(parsed * (contextUnit.toLowerCase() === 'm' ? 1000000 : 1000));
}
export function getModelContextIdentifier(model?: string | ModelIdentifier): string | undefined {
if (typeof model === 'string') {
const trimmed = model.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
if (!model) {
return undefined;
}
const id = model.id?.trim();
const displayName = model.display_name?.trim();
if (id && displayName) {
return `${id} ${displayName}`;
}
return id ?? displayName;
}
export function getContextConfig(modelIdentifier?: string, contextWindowSize?: number | null): ModelContextConfig {
const statusWindowSize = toValidWindowSize(contextWindowSize);
if (statusWindowSize !== null) {
return {
maxTokens: statusWindowSize,
usableTokens: Math.floor(statusWindowSize * 0.8)
usableTokens: Math.floor(statusWindowSize * USABLE_CONTEXT_RATIO)
};
}
// Default to 200k for older models
const defaultConfig = {
maxTokens: 200000,
usableTokens: 160000
maxTokens: DEFAULT_CONTEXT_WINDOW_SIZE,
usableTokens: Math.floor(DEFAULT_CONTEXT_WINDOW_SIZE * USABLE_CONTEXT_RATIO)
};
if (!modelId)
if (!modelIdentifier) {
return defaultConfig;
}
// Any model with [1m] suffix has 1M context
if (modelId.toLowerCase().includes('[1m]')) {
const inferredWindowSize = parseContextWindowSize(modelIdentifier);
if (inferredWindowSize !== null) {
return {
maxTokens: 1000000,
usableTokens: 800000 // 80% of 1M
maxTokens: inferredWindowSize,
usableTokens: Math.floor(inferredWindowSize * USABLE_CONTEXT_RATIO)
};
}
// Add future models here as needed
return defaultConfig;
}
+6 -4
View File
@@ -7,7 +7,10 @@ import type {
WidgetItem
} from '../types/Widget';
import { getContextWindowMetrics } from '../utils/context-window';
import { getContextConfig } from '../utils/model-context';
import {
getContextConfig,
getModelContextIdentifier
} from '../utils/model-context';
import { makeUsageProgressBar } from '../utils/usage';
type DisplayMode = 'progress' | 'progress-short';
@@ -72,9 +75,8 @@ export class ContextBarWidget implements Widget {
}
if (total === null && context.tokenMetrics) {
const model = context.data?.model;
const modelId = typeof model === 'string' ? model : model?.id;
total = getContextConfig(modelId).maxTokens;
const modelIdentifier = getModelContextIdentifier(context.data?.model);
total = getContextConfig(modelIdentifier).maxTokens;
}
if (used === null || total === null || total <= 0) {
+6 -4
View File
@@ -7,7 +7,10 @@ import type {
WidgetItem
} from '../types/Widget';
import { getContextWindowMetrics } from '../utils/context-window';
import { getContextConfig } from '../utils/model-context';
import {
getContextConfig,
getModelContextIdentifier
} from '../utils/model-context';
import {
getContextInverseModifierText,
@@ -47,9 +50,8 @@ export class ContextPercentageWidget implements Widget {
}
if (context.tokenMetrics) {
const model = context.data?.model;
const modelId = typeof model === 'string' ? model : model?.id;
const contextConfig = getContextConfig(modelId, contextWindowMetrics.windowSize);
const modelIdentifier = getModelContextIdentifier(context.data?.model);
const contextConfig = getContextConfig(modelIdentifier, contextWindowMetrics.windowSize);
const usedPercentage = Math.min(100, (context.tokenMetrics.contextLength / contextConfig.maxTokens) * 100);
const displayPercentage = isInverse ? (100 - usedPercentage) : usedPercentage;
return formatRawOrLabeledValue(item, 'Ctx: ', `${displayPercentage.toFixed(1)}%`);
+6 -4
View File
@@ -7,7 +7,10 @@ import type {
WidgetItem
} from '../types/Widget';
import { getContextWindowMetrics } from '../utils/context-window';
import { getContextConfig } from '../utils/model-context';
import {
getContextConfig,
getModelContextIdentifier
} from '../utils/model-context';
import {
getContextInverseModifierText,
@@ -34,10 +37,9 @@ export class ContextPercentageUsableWidget implements Widget {
render(item: WidgetItem, context: RenderContext, settings: Settings): string | null {
const isInverse = isContextInverse(item);
const model = context.data?.model;
const modelId = typeof model === 'string' ? model : model?.id;
const modelIdentifier = getModelContextIdentifier(context.data?.model);
const contextWindowMetrics = getContextWindowMetrics(context.data);
const contextConfig = getContextConfig(modelId, contextWindowMetrics.windowSize);
const contextConfig = getContextConfig(modelIdentifier, contextWindowMetrics.windowSize);
if (context.isPreview) {
const previewValue = isInverse ? '88.4%' : '11.6%';
+32
View File
@@ -47,6 +47,38 @@ describe('ContextBarWidget', () => {
expect(widget.render({ id: 'ctx', type: 'context-bar' }, context, DEFAULT_SETTINGS)).toBe('Context: [bar:25.0:16] 50k/200k (25%)');
});
it('uses 1M context label model IDs in fallback mode', () => {
const context: RenderContext = {
data: { model: { id: 'Opus 4.6 (1M context)' } },
tokenMetrics: {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
totalTokens: 0,
contextLength: 50000
}
};
const widget = new ContextBarWidget();
expect(widget.render({ id: 'ctx', type: 'context-bar' }, context, DEFAULT_SETTINGS)).toBe('Context: [bar:5.0:16] 50k/1000k (5%)');
});
it('uses 1M in parentheses model IDs in fallback mode', () => {
const context: RenderContext = {
data: { model: { id: 'Opus 4.6 (1M)' } },
tokenMetrics: {
inputTokens: 0,
outputTokens: 0,
cachedTokens: 0,
totalTokens: 0,
contextLength: 50000
}
};
const widget = new ContextBarWidget();
expect(widget.render({ id: 'ctx', type: 'context-bar' }, context, DEFAULT_SETTINGS)).toBe('Context: [bar:5.0:16] 50k/1000k (5%)');
});
it('clamps usage percentage to 100 when context length exceeds total', () => {
const context: RenderContext = {
data: {
@@ -89,6 +89,16 @@ describe('ContextPercentageWidget', () => {
const result = render('claude-sonnet-4-5-20250929[1m]', 42000, true);
expect(result).toBe('4.2%');
});
it('should calculate percentage using 1M denominator for 1M context label model IDs', () => {
const result = render('Opus 4.6 (1M context)', 42000);
expect(result).toBe('Ctx: 4.2%');
});
it('should calculate percentage using 1M denominator for 1M in parentheses model IDs', () => {
const result = render('Opus 4.6 (1M)', 42000);
expect(result).toBe('Ctx: 4.2%');
});
});
describe('Older models with 200k context window', () => {
@@ -121,6 +121,16 @@ describe('ContextPercentageUsableWidget', () => {
const result = render('claude-sonnet-4-5-20250929[1M]', 42000);
expect(result).toBe('Ctx(u): 5.3%');
});
it('uses 1M context labels in model id for fallback denominator', () => {
const result = render('Opus 4.6 (1M context)', 42000);
expect(result).toBe('Ctx(u): 5.3%');
});
it('uses 1M in parentheses in model id for fallback denominator', () => {
const result = render('Opus 4.6 (1M)', 42000);
expect(result).toBe('Ctx(u): 5.3%');
});
});
describe('Older models with 160k usable tokens', () => {