Fix: Dynamic context window calculation for Sonnet 4.5 (1M tokens) (#107)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Ethan StarkandClaude authored and GitHub committed 2025-11-13 08:53:54 -05:00
1 parent 8477dc5e6b
commit da325930ad
11 files changed
+361 -14

No files matched your search

+15 -4
View File
@@ -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
- **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
+2 -2
View File
@@ -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)
@@ -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);
});
});
});
+71
View File
@@ -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);
});
});
});
+17
View File
@@ -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);
}
+29
View File
@@ -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;
}
+3 -4
View File
@@ -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
+5 -2
View File
@@ -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)}%`;
}
+5 -2
View File
@@ -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)}%`;
}
@@ -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%');
});
});
});
@@ -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%');
});
});
});