mirror of
https://github.com/tiennm99/ccstatusline.git
synced 2026-10-14 02:15:32 +00:00
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:
1 parent
b90840f68c
commit
7e2ad30f77
12 files changed
+260
-32
No files matched your search
@@ -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
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)}%`);
|
||||
|
||||
@@ -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%';
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user