diff --git a/README.md b/README.md index 25ed1e5..3e22ebd 100644 --- a/README.md +++ b/README.md @@ -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") diff --git a/package.json b/package.json index 2d6f1c9..46528b8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/utils/__tests__/context-percentage.test.ts b/src/utils/__tests__/context-percentage.test.ts index 4468b88..8f85c78 100644 --- a/src/utils/__tests__/context-percentage.test.ts +++ b/src/utils/__tests__/context-percentage.test.ts @@ -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', () => { diff --git a/src/utils/__tests__/model-context.test.ts b/src/utils/__tests__/model-context.test.ts index 7dbdbb2..bfd4a11 100644 --- a/src/utils/__tests__/model-context.test.ts +++ b/src/utils/__tests__/model-context.test.ts @@ -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(); + }); }); \ No newline at end of file diff --git a/src/utils/context-percentage.ts b/src/utils/context-percentage.ts index aab354f..1d536cf 100644 --- a/src/utils/context-percentage.ts +++ b/src/utils/context-percentage.ts @@ -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); } \ No newline at end of file diff --git a/src/utils/model-context.ts b/src/utils/model-context.ts index 5c455f7..be42dee 100644 --- a/src/utils/model-context.ts +++ b/src/utils/model-context.ts @@ -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; } \ No newline at end of file diff --git a/src/widgets/ContextBar.ts b/src/widgets/ContextBar.ts index a2debff..8969429 100644 --- a/src/widgets/ContextBar.ts +++ b/src/widgets/ContextBar.ts @@ -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) { diff --git a/src/widgets/ContextPercentage.ts b/src/widgets/ContextPercentage.ts index fada414..2f9ffa6 100644 --- a/src/widgets/ContextPercentage.ts +++ b/src/widgets/ContextPercentage.ts @@ -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)}%`); diff --git a/src/widgets/ContextPercentageUsable.ts b/src/widgets/ContextPercentageUsable.ts index ce293cd..e2de517 100644 --- a/src/widgets/ContextPercentageUsable.ts +++ b/src/widgets/ContextPercentageUsable.ts @@ -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%'; diff --git a/src/widgets/__tests__/ContextBar.test.ts b/src/widgets/__tests__/ContextBar.test.ts index 7a1f1c0..342c2eb 100644 --- a/src/widgets/__tests__/ContextBar.test.ts +++ b/src/widgets/__tests__/ContextBar.test.ts @@ -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: { diff --git a/src/widgets/__tests__/ContextPercentage.test.ts b/src/widgets/__tests__/ContextPercentage.test.ts index ddc83fe..beba7d8 100644 --- a/src/widgets/__tests__/ContextPercentage.test.ts +++ b/src/widgets/__tests__/ContextPercentage.test.ts @@ -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', () => { diff --git a/src/widgets/__tests__/ContextPercentageUsable.test.ts b/src/widgets/__tests__/ContextPercentageUsable.test.ts index 2121f39..98945af 100644 --- a/src/widgets/__tests__/ContextPercentageUsable.test.ts +++ b/src/widgets/__tests__/ContextPercentageUsable.test.ts @@ -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', () => {