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