From da325930ad8df357fbad240848b535308acc6054 Mon Sep 17 00:00:00 2001 From: Ethan Stark <19176630+EthanJStark@users.noreply.github.com> Date: Thu, 13 Nov 2025 08:53:54 -0500 Subject: [PATCH] Fix: Dynamic context window calculation for Sonnet 4.5 (1M tokens) (#107) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix: Dynamic context window calculation for Sonnet 4.5 (1M tokens) - Create model-context utility with dynamic context window mapping - Sonnet 4.5 models now use 1M context (800k usable) - Older models default to 200k context (160k usable) - Update ContextPercentage and ContextPercentageUsable widgets - Update renderer.ts flex mode calculations (2 locations) - Add comprehensive test coverage (14 new tests, 36 total passing) Fixes incorrect percentage display for Sonnet 4.5: - Before: 42k tokens showed 21.0% (using 200k denominator) - After: 42k tokens shows 4.2% (using 1M denominator) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Style: Apply ESLint auto-fixes for consistency - Standardize indentation to 4 spaces - Apply formatting rules from eslint.config.js - No logic changes, formatting only 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude * Refactor: Code review fixes for context window changes - Fix misleading test name (line 69 of context-percentage.test.ts) - Strengthen model ID pattern matching (claude-sonnet-4-5 vs sonnet-4-5) - Standardize export style (inline export for consistency) All tests passing (19/19 for affected files) TypeScript and ESLint checks passed * Enhance CLAUDE.md: Update piped input example for Sonnet 4.5 and add test commands - Changed piped input example to reflect the new model ID for Claude 4.5 - Added instructions for running tests and tests in watch mode - Updated context window metrics description to include dynamic model-based context windows Improves clarity and usability for developers working with the project. * Fix: Require [1m] suffix for Sonnet 4.5 1M context window detection Address PR feedback that Sonnet 4.5 only has 1M context on API usage when the model ID includes the [1m] suffix (long context beta access). Changes: - Update getContextConfig() to check for [1m] suffix (case-insensitive) - Models without suffix now safely default to 200K context - Add comprehensive test coverage for suffix detection - Update all test fixtures to use correct model ID format - Document suffix requirement in CLAUDE.md This ensures conservative behavior - only models with explicit [1m] suffix receive 1M context allocation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --------- Co-authored-by: Claude --- CLAUDE.md | 19 ++++- README.md | 4 +- .../__tests__/context-percentage.test.ts | 84 +++++++++++++++++++ src/utils/__tests__/model-context.test.ts | 71 ++++++++++++++++ src/utils/context-percentage.ts | 17 ++++ src/utils/model-context.ts | 29 +++++++ src/utils/renderer.ts | 7 +- src/widgets/ContextPercentage.ts | 7 +- src/widgets/ContextPercentageUsable.ts | 7 +- .../__tests__/ContextPercentage.test.ts | 65 ++++++++++++++ .../__tests__/ContextPercentageUsable.test.ts | 65 ++++++++++++++ 11 files changed, 361 insertions(+), 14 deletions(-) create mode 100644 src/utils/__tests__/context-percentage.test.ts create mode 100644 src/utils/__tests__/model-context.test.ts create mode 100644 src/utils/context-percentage.ts create mode 100644 src/utils/model-context.ts create mode 100644 src/widgets/__tests__/ContextPercentage.test.ts create mode 100644 src/widgets/__tests__/ContextPercentageUsable.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index b8db2dd..d5b6e8f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,12 +20,18 @@ bun run start # Run directly (TUI mode) bun run statusline -# Test with piped input -echo '{"model":{"display_name":"Claude 3.5 Sonnet"},"transcript_path":"test.jsonl"}' | bun run src/ccstatusline.ts +# Test with piped input (use [1m] suffix for 1M context models) +echo '{"model":{"id":"claude-sonnet-4-5-20250929[1m]"},"transcript_path":"test.jsonl"}' | bun run src/ccstatusline.ts # Build for npm distribution bun run build # Creates dist/ccstatusline.js with Node.js 14+ compatibility +# Run tests +bun test + +# Run tests in watch mode +bun test --watch + # Lint and type check bun run lint # Runs TypeScript type checking and ESLint with auto-fix ``` @@ -62,13 +68,18 @@ The project has dual runtime compatibility - works with both Bun and Node.js: - Detects installation status and manages settings.json updates - Validates config directory paths with proper error handling - **colors.ts**: Color definitions and ANSI code mapping +- **model-context.ts**: Model-to-context-window mapping + - Maps model IDs to their context window sizes based on [1m] suffix + - Sonnet 4.5 WITH [1m] suffix: 1M tokens (800k usable at 80%) - requires long context beta access + - Sonnet 4.5 WITHOUT [1m] suffix: 200k tokens (160k usable at 80%) + - Legacy models: 200k tokens (160k usable at 80%) ### Widgets (src/widgets/) Custom widgets implementing the StatusItemWidget interface: - Model, Version, OutputStyle - Claude Code metadata display - GitBranch, GitChanges - Git repository status - TokensInput, TokensOutput, TokensCached, TokensTotal - Token usage metrics -- ContextLength, ContextPercentage, ContextPercentageUsable - Context window metrics +- ContextLength, ContextPercentage, ContextPercentageUsable - Context window metrics (uses dynamic model-based context windows: 1M for Sonnet 4.5 with [1m] suffix, 200k for all other models) - BlockTimer, SessionClock - Time tracking - CurrentWorkingDir, TerminalWidth - Environment info @@ -100,4 +111,4 @@ Default to using Bun instead of Node.js: - **Dependencies**: All runtime dependencies are bundled using `--packages=external` for npm package - **Type checking and linting**: Only run via `bun run lint` command, never using `npx eslint` or `eslint` directly. Never run `tsx`, `bun tsc` or any other variation - **Lint rules**: Never disable a lint rule via a comment, no matter how benign the lint warning or error may seem -- **Testing**: No test framework is currently configured. Manual testing is done via piped input and TUI interaction \ No newline at end of file +- **Testing**: Uses Bun's built-in test framework. Run tests with `bun test` (36 tests covering model context, widgets, and rendering). Manual testing also available via piped input and TUI interaction \ No newline at end of file diff --git a/README.md b/README.md index 0b879fe..ff6b4b6 100644 --- a/README.md +++ b/README.md @@ -364,8 +364,8 @@ Once configured, ccstatusline automatically formats your Claude Code status line - **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 (out of 200k) -- **Context Percentage (usable)** - Shows percentage of usable context (out of 160k, accounting for auto-compact at 80%) +- **Context Percentage** - Shows percentage of context limit used (dynamic: 1M for Sonnet 4.5 with `[1m]` suffix, 200k otherwise) +- **Context Percentage (usable)** - Shows percentage of usable context (dynamic: 800k for Sonnet 4.5 with `[1m]` suffix, 160k otherwise, accounting for auto-compact at 80%) - **Terminal Width** - Shows detected terminal width (for debugging) - **Custom Text** - Add your own custom text to the status line - **Custom Command** - Execute shell commands and display their output (refreshes whenever the statusline is updated by Claude Code) diff --git a/src/utils/__tests__/context-percentage.test.ts b/src/utils/__tests__/context-percentage.test.ts new file mode 100644 index 0000000..ae2f498 --- /dev/null +++ b/src/utils/__tests__/context-percentage.test.ts @@ -0,0 +1,84 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import type { RenderContext } from '../../types'; +import { calculateContextPercentage } from '../context-percentage'; + +describe('calculateContextPercentage', () => { + describe('Sonnet 4.5 with 1M context window', () => { + it('should calculate percentage using 1M denominator with [1m] suffix', () => { + const context: RenderContext = { + data: { model: { id: 'claude-sonnet-4-5-20250929[1m]' } }, + tokenMetrics: { + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + totalTokens: 0, + contextLength: 42000 + } + }; + + const percentage = calculateContextPercentage(context); + expect(percentage).toBe(4.2); + }); + + it('should cap at 100% with [1m] suffix', () => { + const context: RenderContext = { + data: { model: { id: 'claude-sonnet-4-5-20250929[1m]' } }, + tokenMetrics: { + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + totalTokens: 0, + contextLength: 2000000 + } + }; + + const percentage = calculateContextPercentage(context); + expect(percentage).toBe(100); + }); + }); + + describe('Older models with 200k context window', () => { + it('should calculate percentage using 200k denominator', () => { + const context: RenderContext = { + data: { model: { id: 'claude-3-5-sonnet-20241022' } }, + tokenMetrics: { + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + totalTokens: 0, + contextLength: 42000 + } + }; + + const percentage = calculateContextPercentage(context); + expect(percentage).toBe(21.0); + }); + + it('should return 0 when no token metrics', () => { + const context: RenderContext = { data: { model: { id: 'claude-3-5-sonnet-20241022' } } }; + + const percentage = calculateContextPercentage(context); + expect(percentage).toBe(0); + }); + + it('should use default 200k context when model ID is undefined', () => { + const context: RenderContext = { + tokenMetrics: { + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + totalTokens: 0, + contextLength: 42000 + } + }; + + const percentage = calculateContextPercentage(context); + expect(percentage).toBe(21.0); + }); + }); +}); \ No newline at end of file diff --git a/src/utils/__tests__/model-context.test.ts b/src/utils/__tests__/model-context.test.ts new file mode 100644 index 0000000..30bb923 --- /dev/null +++ b/src/utils/__tests__/model-context.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; + +import { getContextConfig } from "../model-context"; + +describe("getContextConfig", () => { + describe("Sonnet 4.5 models with [1m] suffix", () => { + it("should return 1M context window for claude-sonnet-4-5 with [1m] suffix", () => { + const config = getContextConfig("claude-sonnet-4-5-20250929[1m]"); + + expect(config.maxTokens).toBe(1000000); + expect(config.usableTokens).toBe(800000); + }); + + it("should return 1M context window for AWS Bedrock format with [1m] suffix", () => { + const config = getContextConfig( + "us.anthropic.claude-sonnet-4-5-20250929-v1:0[1m]" + ); + + expect(config.maxTokens).toBe(1000000); + expect(config.usableTokens).toBe(800000); + }); + + it("should return 1M context window with uppercase [1M] suffix", () => { + const config = getContextConfig("claude-sonnet-4-5-20250929[1M]"); + + expect(config.maxTokens).toBe(1000000); + expect(config.usableTokens).toBe(800000); + }); + }); + + describe("Sonnet 4.5 models without [1m] suffix", () => { + it("should return 200k context window for claude-sonnet-4-5 without [1m] suffix", () => { + const config = getContextConfig("claude-sonnet-4-5-20250929"); + + expect(config.maxTokens).toBe(200000); + expect(config.usableTokens).toBe(160000); + }); + + it("should return 200k context window for AWS Bedrock format without [1m] suffix", () => { + const config = getContextConfig( + "us.anthropic.claude-sonnet-4-5-20250929-v1:0" + ); + + expect(config.maxTokens).toBe(200000); + expect(config.usableTokens).toBe(160000); + }); + }); + + describe("Older/default models", () => { + it("should return 200k context window for older Sonnet 3.5 model", () => { + const config = getContextConfig("claude-3-5-sonnet-20241022"); + + expect(config.maxTokens).toBe(200000); + expect(config.usableTokens).toBe(160000); + }); + + it("should return 200k context window when model ID is undefined", () => { + const config = getContextConfig(undefined); + + expect(config.maxTokens).toBe(200000); + expect(config.usableTokens).toBe(160000); + }); + + it("should return 200k context window for unknown model ID", () => { + const config = getContextConfig("claude-unknown-model"); + + expect(config.maxTokens).toBe(200000); + expect(config.usableTokens).toBe(160000); + }); + }); +}); diff --git a/src/utils/context-percentage.ts b/src/utils/context-percentage.ts new file mode 100644 index 0000000..14872ec --- /dev/null +++ b/src/utils/context-percentage.ts @@ -0,0 +1,17 @@ +import type { RenderContext } from '../types'; + +import { getContextConfig } from './model-context'; + +/** + * Calculate context window usage percentage based on model's max tokens + */ +export function calculateContextPercentage(context: RenderContext): number { + if (!context.tokenMetrics) { + return 0; + } + + const modelId = context.data?.model?.id; + const contextConfig = getContextConfig(modelId); + + 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 new file mode 100644 index 0000000..186a7c0 --- /dev/null +++ b/src/utils/model-context.ts @@ -0,0 +1,29 @@ +interface ModelContextConfig { + maxTokens: number; + usableTokens: number; +} + +export function getContextConfig(modelId?: string): ModelContextConfig { + // Default to 200k for older models + const defaultConfig = { + maxTokens: 200000, + usableTokens: 160000, + }; + + if (!modelId) return defaultConfig; + + // Sonnet 4.5 variants with 1M context (requires [1m] suffix for long context beta) + if ( + modelId.includes("claude-sonnet-4-5") && + modelId.toLowerCase().includes("[1m]") + ) { + return { + maxTokens: 1000000, + usableTokens: 800000, // 80% of 1M + }; + } + + // Add future models here as needed + + return defaultConfig; +} diff --git a/src/utils/renderer.ts b/src/utils/renderer.ts index 70ce906..c77ef59 100644 --- a/src/utils/renderer.ts +++ b/src/utils/renderer.ts @@ -17,6 +17,7 @@ import { getColorAnsiCode, getPowerlineTheme } from './colors'; +import { calculateContextPercentage } from './context-percentage'; import { getTerminalWidth } from './terminal'; import { getWidget } from './widgets'; @@ -101,8 +102,7 @@ function renderPowerlineStatusLine( terminalWidth = detectedWidth - 40; } else if (flexMode === 'full-until-compact') { const threshold = settings.compactThreshold; - const contextPercentage = context.tokenMetrics - ? Math.min(100, (context.tokenMetrics.contextLength / 200000) * 100) : 0; + const contextPercentage = calculateContextPercentage(context); if (contextPercentage >= threshold) { terminalWidth = detectedWidth - 40; @@ -680,8 +680,7 @@ export function renderStatusLine( } else if (flexMode === 'full-until-compact') { // Check context percentage to decide const threshold = settings.compactThreshold; - const contextPercentage = context.tokenMetrics - ? Math.min(100, (context.tokenMetrics.contextLength / 200000) * 100) : 0; + const contextPercentage = calculateContextPercentage(context); if (contextPercentage >= threshold) { // Context is high, leave space for auto-compact diff --git a/src/widgets/ContextPercentage.ts b/src/widgets/ContextPercentage.ts index dd2c40f..3369204 100644 --- a/src/widgets/ContextPercentage.ts +++ b/src/widgets/ContextPercentage.ts @@ -6,10 +6,11 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { getContextConfig } from '../utils/model-context'; export class ContextPercentageWidget implements Widget { getDefaultColor(): string { return 'blue'; } - getDescription(): string { return 'Shows percentage of context window used or remaining (of 200k tokens)'; } + getDescription(): string { return 'Shows percentage of context window used or remaining'; } getDisplayName(): string { return 'Context %'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { const isInverse = item.metadata?.inverse === 'true'; @@ -46,7 +47,9 @@ export class ContextPercentageWidget implements Widget { const previewValue = isInverse ? '90.7%' : '9.3%'; return item.rawValue ? previewValue : `Ctx: ${previewValue}`; } else if (context.tokenMetrics) { - const usedPercentage = Math.min(100, (context.tokenMetrics.contextLength / 200000) * 100); + const modelId = context.data?.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)}%`; } diff --git a/src/widgets/ContextPercentageUsable.ts b/src/widgets/ContextPercentageUsable.ts index 874e1af..8b0e138 100644 --- a/src/widgets/ContextPercentageUsable.ts +++ b/src/widgets/ContextPercentageUsable.ts @@ -6,10 +6,11 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { getContextConfig } from '../utils/model-context'; export class ContextPercentageUsableWidget implements Widget { getDefaultColor(): string { return 'green'; } - getDescription(): string { return 'Shows percentage of usable context window used or remaining (of 160k tokens before auto-compact)'; } + getDescription(): string { return 'Shows percentage of usable context window used or remaining (80% of max before auto-compact)'; } getDisplayName(): string { return 'Context % (usable)'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { const isInverse = item.metadata?.inverse === 'true'; @@ -46,7 +47,9 @@ export class ContextPercentageUsableWidget implements Widget { const previewValue = isInverse ? '88.4%' : '11.6%'; return item.rawValue ? previewValue : `Ctx(u): ${previewValue}`; } else if (context.tokenMetrics) { - const usedPercentage = Math.min(100, (context.tokenMetrics.contextLength / 160000) * 100); + const modelId = context.data?.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)}%`; } diff --git a/src/widgets/__tests__/ContextPercentage.test.ts b/src/widgets/__tests__/ContextPercentage.test.ts new file mode 100644 index 0000000..111e672 --- /dev/null +++ b/src/widgets/__tests__/ContextPercentage.test.ts @@ -0,0 +1,65 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import type { + RenderContext, + WidgetItem +} from '../../types'; +import { DEFAULT_SETTINGS } from '../../types/Settings'; +import { ContextPercentageWidget } from '../ContextPercentage'; + +function render(modelId: string | undefined, contextLength: number, rawValue = false, inverse = false) { + const widget = new ContextPercentageWidget(); + const context: RenderContext = { + data: modelId ? { model: { id: modelId } } : undefined, + tokenMetrics: { + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + totalTokens: 0, + contextLength + } + }; + const item: WidgetItem = { + id: 'context-percentage', + type: 'context-percentage', + rawValue, + metadata: inverse ? { inverse: 'true' } : undefined + }; + + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('ContextPercentageWidget', () => { + describe('Sonnet 4.5 with 1M context window', () => { + it('should calculate percentage using 1M denominator for Sonnet 4.5 with [1m] suffix', () => { + const result = render('claude-sonnet-4-5-20250929[1m]', 42000); + expect(result).toBe('Ctx: 4.2%'); + }); + + it('should calculate percentage using 1M denominator for Sonnet 4.5 (raw value) with [1m] suffix', () => { + const result = render('claude-sonnet-4-5-20250929[1m]', 42000, true); + expect(result).toBe('4.2%'); + }); + }); + + describe('Older models with 200k context window', () => { + it('should calculate percentage using 200k denominator for older Sonnet 3.5', () => { + const result = render('claude-3-5-sonnet-20241022', 42000); + expect(result).toBe('Ctx: 21.0%'); + }); + + it('should calculate percentage using 200k denominator when model ID is undefined', () => { + const result = render(undefined, 42000); + expect(result).toBe('Ctx: 21.0%'); + }); + + it('should calculate percentage using 200k denominator for unknown model', () => { + const result = render('claude-unknown-model', 42000); + expect(result).toBe('Ctx: 21.0%'); + }); + }); +}); \ No newline at end of file diff --git a/src/widgets/__tests__/ContextPercentageUsable.test.ts b/src/widgets/__tests__/ContextPercentageUsable.test.ts new file mode 100644 index 0000000..60595a0 --- /dev/null +++ b/src/widgets/__tests__/ContextPercentageUsable.test.ts @@ -0,0 +1,65 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import type { + RenderContext, + WidgetItem +} from '../../types'; +import { DEFAULT_SETTINGS } from '../../types/Settings'; +import { ContextPercentageUsableWidget } from '../ContextPercentageUsable'; + +function render(modelId: string | undefined, contextLength: number, rawValue = false, inverse = false) { + const widget = new ContextPercentageUsableWidget(); + const context: RenderContext = { + data: modelId ? { model: { id: modelId } } : undefined, + tokenMetrics: { + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + totalTokens: 0, + contextLength + } + }; + const item: WidgetItem = { + id: 'context-percentage-usable', + type: 'context-percentage-usable', + rawValue, + metadata: inverse ? { inverse: 'true' } : undefined + }; + + return widget.render(item, context, DEFAULT_SETTINGS); +} + +describe('ContextPercentageUsableWidget', () => { + describe('Sonnet 4.5 with 800k usable tokens', () => { + it('should calculate percentage using 800k denominator for Sonnet 4.5 with [1m] suffix', () => { + const result = render('claude-sonnet-4-5-20250929[1m]', 42000); + expect(result).toBe('Ctx(u): 5.3%'); + }); + + it('should calculate percentage using 800k denominator for Sonnet 4.5 (raw value) with [1m] suffix', () => { + const result = render('claude-sonnet-4-5-20250929[1m]', 42000, true); + expect(result).toBe('5.3%'); + }); + }); + + describe('Older models with 160k usable tokens', () => { + it('should calculate percentage using 160k denominator for older Sonnet 3.5', () => { + const result = render('claude-3-5-sonnet-20241022', 42000); + expect(result).toBe('Ctx(u): 26.3%'); + }); + + it('should calculate percentage using 160k denominator when model ID is undefined', () => { + const result = render(undefined, 42000); + expect(result).toBe('Ctx(u): 26.3%'); + }); + + it('should calculate percentage using 160k denominator for unknown model', () => { + const result = render('claude-unknown-model', 42000); + expect(result).toBe('Ctx(u): 26.3%'); + }); + }); +}); \ No newline at end of file