diff --git a/src/utils/__tests__/claude-compaction.test.ts b/src/utils/__tests__/claude-compaction.test.ts new file mode 100644 index 00000000..1cca55a3 --- /dev/null +++ b/src/utils/__tests__/claude-compaction.test.ts @@ -0,0 +1,224 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + it +} from 'vitest'; + +import { getCompactionOverrides } from '../claude-compaction'; + +// Capture original env values up-front so afterAll can restore them whether +// or not the test that mutated them ran successfully. +const ORIGINAL_ENV = { + CLAUDE_CODE_AUTO_COMPACT_WINDOW: process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW, + CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE, + DISABLE_AUTO_COMPACT: process.env.DISABLE_AUTO_COMPACT, + CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR +}; + +function clearEnv(): void { + delete process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW; + delete process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE; + delete process.env.DISABLE_AUTO_COMPACT; + delete process.env.CLAUDE_CONFIG_DIR; +} + +function restoreEnv(): void { + if (ORIGINAL_ENV.CLAUDE_CODE_AUTO_COMPACT_WINDOW === undefined) { + delete process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW; + } else { + process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = ORIGINAL_ENV.CLAUDE_CODE_AUTO_COMPACT_WINDOW; + } + if (ORIGINAL_ENV.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE === undefined) { + delete process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE; + } else { + process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE = ORIGINAL_ENV.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE; + } + if (ORIGINAL_ENV.DISABLE_AUTO_COMPACT === undefined) { + delete process.env.DISABLE_AUTO_COMPACT; + } else { + process.env.DISABLE_AUTO_COMPACT = ORIGINAL_ENV.DISABLE_AUTO_COMPACT; + } + if (ORIGINAL_ENV.CLAUDE_CONFIG_DIR === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = ORIGINAL_ENV.CLAUDE_CONFIG_DIR; + } +} + +let testUserDir = ''; +let testProjectDir = ''; + +function writeUserSettings(content: unknown, file = 'settings.json'): void { + const settingsPath = path.join(testUserDir, file); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync( + settingsPath, + typeof content === 'string' ? content : JSON.stringify(content), + 'utf-8' + ); +} + +function writeProjectSettings(content: unknown, file = 'settings.json'): void { + const settingsPath = path.join(testProjectDir, '.claude', file); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync( + settingsPath, + typeof content === 'string' ? content : JSON.stringify(content), + 'utf-8' + ); +} + +beforeEach(() => { + clearEnv(); + testUserDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-compaction-user-')); + testProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-compaction-project-')); + process.env.CLAUDE_CONFIG_DIR = testUserDir; +}); + +afterEach(() => { + if (testUserDir) { + fs.rmSync(testUserDir, { recursive: true, force: true }); + } + if (testProjectDir) { + fs.rmSync(testProjectDir, { recursive: true, force: true }); + } + clearEnv(); +}); + +afterAll(() => { + restoreEnv(); +}); + +describe('getCompactionOverrides', () => { + describe('with no env vars and no settings files', () => { + it('returns null for both fields', () => { + expect(getCompactionOverrides(testProjectDir)).toEqual({ + effectiveWindow: null, + ratio: null + }); + }); + }); + + describe('effectiveWindow resolution', () => { + it('reads CLAUDE_CODE_AUTO_COMPACT_WINDOW from env', () => { + process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = '300000'; + expect(getCompactionOverrides(testProjectDir).effectiveWindow).toBe(300000); + }); + + it('reads autoCompactWindow from user settings.json', () => { + writeUserSettings({ autoCompactWindow: 200000 }); + expect(getCompactionOverrides(testProjectDir).effectiveWindow).toBe(200000); + }); + + it('reads autoCompactWindow from project settings.json', () => { + writeProjectSettings({ autoCompactWindow: 150000 }); + expect(getCompactionOverrides(testProjectDir).effectiveWindow).toBe(150000); + }); + + it('prefers env var over settings.json', () => { + process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = '50000'; + writeUserSettings({ autoCompactWindow: 999999 }); + writeProjectSettings({ autoCompactWindow: 999999 }); + expect(getCompactionOverrides(testProjectDir).effectiveWindow).toBe(50000); + }); + + it('prefers project settings over user settings', () => { + writeUserSettings({ autoCompactWindow: 999999 }); + writeProjectSettings({ autoCompactWindow: 100000 }); + expect(getCompactionOverrides(testProjectDir).effectiveWindow).toBe(100000); + }); + + it('prefers project .local over project base', () => { + writeProjectSettings({ autoCompactWindow: 999999 }, 'settings.json'); + writeProjectSettings({ autoCompactWindow: 75000 }, 'settings.local.json'); + expect(getCompactionOverrides(testProjectDir).effectiveWindow).toBe(75000); + }); + + it('ignores non-numeric or non-positive autoCompactWindow', () => { + writeUserSettings({ autoCompactWindow: 'lots' }); + expect(getCompactionOverrides(testProjectDir).effectiveWindow).toBeNull(); + + writeUserSettings({ autoCompactWindow: 0 }); + expect(getCompactionOverrides(testProjectDir).effectiveWindow).toBeNull(); + + writeUserSettings({ autoCompactWindow: -100 }); + expect(getCompactionOverrides(testProjectDir).effectiveWindow).toBeNull(); + }); + + it('ignores malformed env var (non-numeric, zero, negative)', () => { + process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = 'huge'; + expect(getCompactionOverrides(testProjectDir).effectiveWindow).toBeNull(); + + process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = '0'; + expect(getCompactionOverrides(testProjectDir).effectiveWindow).toBeNull(); + + process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = '-50000'; + expect(getCompactionOverrides(testProjectDir).effectiveWindow).toBeNull(); + }); + + it('tolerates malformed JSON in settings files', () => { + writeUserSettings('{ not json'); + writeProjectSettings({ autoCompactWindow: 100000 }); + expect(getCompactionOverrides(testProjectDir).effectiveWindow).toBe(100000); + }); + }); + + describe('ratio resolution', () => { + it('returns 1.0 when DISABLE_AUTO_COMPACT=1', () => { + process.env.DISABLE_AUTO_COMPACT = '1'; + expect(getCompactionOverrides(testProjectDir).ratio).toBe(1.0); + }); + + it('also accepts DISABLE_AUTO_COMPACT=true (case-insensitive)', () => { + process.env.DISABLE_AUTO_COMPACT = 'TRUE'; + expect(getCompactionOverrides(testProjectDir).ratio).toBe(1.0); + }); + + it('parses CLAUDE_AUTOCOMPACT_PCT_OVERRIDE as a percentage', () => { + process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE = '60'; + expect(getCompactionOverrides(testProjectDir).ratio).toBeCloseTo(0.6); + }); + + it('prefers DISABLE_AUTO_COMPACT over PCT override when both are set', () => { + process.env.DISABLE_AUTO_COMPACT = '1'; + process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE = '60'; + expect(getCompactionOverrides(testProjectDir).ratio).toBe(1.0); + }); + + it('rejects PCT override outside 1-100', () => { + process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE = '0'; + expect(getCompactionOverrides(testProjectDir).ratio).toBeNull(); + + process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE = '101'; + expect(getCompactionOverrides(testProjectDir).ratio).toBeNull(); + + process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE = 'half'; + expect(getCompactionOverrides(testProjectDir).ratio).toBeNull(); + }); + + it('ignores DISABLE_AUTO_COMPACT when set to a non-truthy value', () => { + process.env.DISABLE_AUTO_COMPACT = '0'; + expect(getCompactionOverrides(testProjectDir).ratio).toBeNull(); + + process.env.DISABLE_AUTO_COMPACT = 'false'; + expect(getCompactionOverrides(testProjectDir).ratio).toBeNull(); + }); + }); + + describe('combined overrides', () => { + it('returns both effectiveWindow and ratio when both are configured', () => { + process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE = '60'; + writeProjectSettings({ autoCompactWindow: 200000 }); + expect(getCompactionOverrides(testProjectDir)).toEqual({ + effectiveWindow: 200000, + ratio: 0.6 + }); + }); + }); +}); diff --git a/src/utils/__tests__/model-context.test.ts b/src/utils/__tests__/model-context.test.ts index 0db129cb..b25e1b54 100644 --- a/src/utils/__tests__/model-context.test.ts +++ b/src/utils/__tests__/model-context.test.ts @@ -126,6 +126,155 @@ describe('getContextConfig', () => { expect(config.usableTokens).toBe(160000); }); }); + + describe('Compaction overrides', () => { + it('uses overrides.effectiveWindow over the model native window', () => { + // 1M model, but autoCompactWindow=200k shrinks the effective window. + const config = getContextConfig( + 'claude-opus-4-6[1m]', + null, + { effectiveWindow: 200000, ratio: null } + ); + + expect(config.maxTokens).toBe(200000); + expect(config.usableTokens).toBe(160000); + }); + + it('uses overrides.effectiveWindow over the status-JSON context_window_size', () => { + const config = getContextConfig( + 'claude-sonnet-4-5-20250929', + 1000000, + { effectiveWindow: 150000 } + ); + + expect(config.maxTokens).toBe(150000); + expect(config.usableTokens).toBe(120000); + }); + + it('uses overrides.ratio to replace the default 0.8 usable ratio', () => { + // CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=60 → ratio=0.6 → 200k * 0.6 = 120k. + const config = getContextConfig( + 'claude-sonnet-4-5-20250929', + null, + { ratio: 0.6 } + ); + + expect(config.maxTokens).toBe(200000); + expect(config.usableTokens).toBe(120000); + }); + + it('uses ratio=1.0 (DISABLE_AUTO_COMPACT) to treat the entire window as usable', () => { + const config = getContextConfig( + 'claude-sonnet-4-5-20250929', + null, + { ratio: 1.0 } + ); + + expect(config.maxTokens).toBe(200000); + expect(config.usableTokens).toBe(200000); + }); + + it('clamps ratio above 1.0 to avoid usableTokens > maxTokens', () => { + const config = getContextConfig( + 'claude-sonnet-4-5-20250929', + null, + { ratio: 1.5 } + ); + + expect(config.maxTokens).toBe(200000); + expect(config.usableTokens).toBe(200000); + }); + + it('falls back to default ratio when overrides.ratio is null/undefined/invalid', () => { + const baseline = getContextConfig('claude-sonnet-4-5-20250929'); + + expect(getContextConfig('claude-sonnet-4-5-20250929', null, { ratio: null })) + .toEqual(baseline); + expect(getContextConfig('claude-sonnet-4-5-20250929', null, { ratio: 0 })) + .toEqual(baseline); + expect(getContextConfig('claude-sonnet-4-5-20250929', null, { ratio: -0.5 })) + .toEqual(baseline); + }); + + it('falls back to status/model window when overrides.effectiveWindow is null', () => { + const config = getContextConfig( + 'claude-opus-4-6[1m]', + null, + { effectiveWindow: null, ratio: 0.5 } + ); + + expect(config.maxTokens).toBe(1000000); + expect(config.usableTokens).toBe(500000); + }); + + it('combines effectiveWindow + ratio for the full override case', () => { + const config = getContextConfig( + 'claude-opus-4-6[1m]', + null, + { effectiveWindow: 200000, ratio: 0.6 } + ); + + expect(config.maxTokens).toBe(200000); + expect(config.usableTokens).toBe(120000); + }); + + it('clamps effectiveWindow to status-JSON native window when override is larger', () => { + // Real-world case: user runs Opus 4.7 (1M) most of the time and + // sets autoCompactWindow=300000 to compact earlier than CC's default. + // When the same user switches to Sonnet 4.5 (200k native), CC caps + // autoCompactWindow to the native window — and so should the widget. + const config = getContextConfig( + 'claude-sonnet-4-5-20250929', + 200000, + { effectiveWindow: 300000 } + ); + + expect(config.maxTokens).toBe(200000); + expect(config.usableTokens).toBe(160000); + }); + + it('clamps effectiveWindow to model-marker-inferred native when override is larger', () => { + // No status JSON window, but the [1m] marker tells us native = 1M. + // Override of 1.5M gets clamped to 1M. + const config = getContextConfig( + 'claude-opus-4-6[1m]', + null, + { effectiveWindow: 1500000 } + ); + + expect(config.maxTokens).toBe(1000000); + expect(config.usableTokens).toBe(800000); + }); + + it('leaves effectiveWindow unclamped when override is smaller than native', () => { + // Override of 200k on an Opus 1M session is the intended use case + // for autoCompactWindow — shrink, not no-op. + const config = getContextConfig( + 'claude-opus-4-6[1m]', + null, + { effectiveWindow: 200000 } + ); + + expect(config.maxTokens).toBe(200000); + expect(config.usableTokens).toBe(160000); + }); + + it('does not clamp when no native-window signal is available', () => { + // Model id has no explicit size marker and no status JSON + // context_window_size — trust the override as-is rather than + // clamping to the 200k default (which would be wrong for users + // running custom proxies or non-Anthropic models whose true + // window might be larger than 200k). + const config = getContextConfig( + 'claude-unknown-model', + null, + { effectiveWindow: 500000 } + ); + + expect(config.maxTokens).toBe(500000); + expect(config.usableTokens).toBe(400000); + }); + }); }); describe('getModelContextIdentifier', () => { diff --git a/src/utils/claude-compaction.ts b/src/utils/claude-compaction.ts new file mode 100644 index 00000000..019fa5b8 --- /dev/null +++ b/src/utils/claude-compaction.ts @@ -0,0 +1,161 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +/** + * Resolves the Claude Code config directory. Inlined (rather than imported + * from `./claude-settings`) to keep this module free of the widget→settings + * import cycle. Mirrors `getClaudeConfigDir` semantics for the read path: + * honor `CLAUDE_CONFIG_DIR` when it points to an existing directory or a + * path that can be created, falling back to `~/.claude`. + */ +function resolveClaudeConfigDir(): string { + const envConfigDir = process.env.CLAUDE_CONFIG_DIR; + if (envConfigDir) { + try { + const resolvedPath = path.resolve(envConfigDir); + if (!fs.existsSync(resolvedPath) || fs.statSync(resolvedPath).isDirectory()) { + return resolvedPath; + } + } catch { + // Fall through to default. + } + } + return path.join(os.homedir(), '.claude'); +} + +/** + * User-configurable overrides that change when Claude Code triggers auto-compact. + * + * - `effectiveWindow`: replaces the model's native context window as the + * denominator for "% used". Sourced from (in priority order): + * 1. `CLAUDE_CODE_AUTO_COMPACT_WINDOW` env var + * 2. `autoCompactWindow` key in any Claude Code settings.json layer + * 3. unset → caller falls back to the model's native window + * + * - `ratio`: the fraction of the effective window that is "usable" before + * Claude Code auto-compacts. Sourced from: + * 1. `DISABLE_AUTO_COMPACT=1` → 1.0 (whole window is usable) + * 2. `CLAUDE_AUTOCOMPACT_PCT_OVERRIDE` (1–100) → value/100 + * 3. unset → null (caller uses its own default, typically 0.8) + * + * Claude Code itself caps `CLAUDE_AUTOCOMPACT_PCT_OVERRIDE` to ~83% via an + * internal `Math.min`, so a value of 100 will not actually disable compaction + * — only `DISABLE_AUTO_COMPACT=1` does that. We pass the raw value through + * (clamped to 1–100) and document the caveat rather than re-implementing CC's + * undocumented cap. + * + * Reference: anthropics/claude-code#46331 (reverse-engineered priority order), + * jarrodwatts/claude-hud#540 (same bug class in sibling status-line project). + */ +export interface CompactionOverrides { + effectiveWindow: number | null; + ratio: number | null; +} + +const PCT_OVERRIDE_MIN = 1; +const PCT_OVERRIDE_MAX = 100; + +function parseEnvWindow(value: string | undefined): number | null { + if (typeof value !== 'string') { + return null; + } + const parsed = Number.parseInt(value.trim(), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return null; + } + return parsed; +} + +function parseEnvPctRatio(value: string | undefined): number | null { + if (typeof value !== 'string') { + return null; + } + const parsed = Number.parseFloat(value.trim()); + if (!Number.isFinite(parsed) || parsed < PCT_OVERRIDE_MIN || parsed > PCT_OVERRIDE_MAX) { + return null; + } + return parsed / 100; +} + +function isEnvFlagTrue(value: string | undefined): boolean { + if (typeof value !== 'string') { + return false; + } + const normalized = value.trim().toLowerCase(); + return normalized === '1' || normalized === 'true'; +} + +function getSettingsCandidatePathsByPriority(cwd: string): string[] { + const userDir = resolveClaudeConfigDir(); + const projectDir = path.join(cwd, '.claude'); + // Match Claude Code's merge order: project > user, .local > base. + const candidates = [ + path.join(projectDir, 'settings.local.json'), + path.join(projectDir, 'settings.json'), + path.join(userDir, 'settings.local.json'), + path.join(userDir, 'settings.json') + ]; + return Array.from(new Set(candidates)); +} + +function tryReadAutoCompactWindow(filePath: string): number | null { + let content: string; + try { + content = fs.readFileSync(filePath, 'utf-8'); + } catch { + return null; + } + + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + return null; + } + + if (!parsed || typeof parsed !== 'object') { + return null; + } + + const value = (parsed as { autoCompactWindow?: unknown }).autoCompactWindow; + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + return null; + } + return value; +} + +function resolveSettingsAutoCompactWindow(cwd: string): number | null { + for (const filePath of getSettingsCandidatePathsByPriority(cwd)) { + const value = tryReadAutoCompactWindow(filePath); + if (value !== null) { + return value; + } + } + return null; +} + +/** + * Resolves the effective compaction overrides for the current Claude Code session. + * + * Reads env vars from `process.env` (CC exports its `env` settings.json block + * into the spawned status line process) and walks the same four settings.json + * layers Claude Code itself merges, returning the highest-priority value for + * each field. + * + * Safe to call on every render — file reads are guarded with try/catch and + * settings.json lookup stops at the first layer that defines `autoCompactWindow`. + * + * @param cwd Working directory to anchor project-scope settings lookup. + * Defaults to `process.cwd()`. + */ +export function getCompactionOverrides(cwd: string = process.cwd()): CompactionOverrides { + const envWindow = parseEnvWindow(process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW); + const effectiveWindow = envWindow ?? resolveSettingsAutoCompactWindow(cwd); + + const ratio = isEnvFlagTrue(process.env.DISABLE_AUTO_COMPACT) + ? 1.0 + : parseEnvPctRatio(process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE); + + return { effectiveWindow, ratio }; +} diff --git a/src/utils/model-context.ts b/src/utils/model-context.ts index 74526e87..73cf98e7 100644 --- a/src/utils/model-context.ts +++ b/src/utils/model-context.ts @@ -8,6 +8,24 @@ interface ModelIdentifier { display_name?: string; } +/** + * Optional Claude Code compaction overrides that change which window / threshold + * the "usable" context calculation should respect. Resolved separately (see + * `getCompactionOverrides` in `./claude-compaction`) so this module stays + * pure — no fs / env access required for callers that don't need overrides. + * + * - `effectiveWindow`: replaces the model's native window size as the + * denominator. When set, the model-inferred / status-JSON window is ignored + * (mirrors Claude Code's priority: `CLAUDE_CODE_AUTO_COMPACT_WINDOW` env and + * `autoCompactWindow` settings.json key both shrink the effective window). + * - `ratio`: replaces the default 0.8 usable ratio. When set, `usableTokens` + * becomes `floor(effectiveWindow * ratio)`. Clamped to (0, 1]. + */ +export interface ContextConfigOverrides { + effectiveWindow?: number | null; + ratio?: number | null; +} + const DEFAULT_CONTEXT_WINDOW_SIZE = 200000; const USABLE_CONTEXT_RATIO = 0.8; @@ -19,6 +37,20 @@ function toValidWindowSize(value: number | null | undefined): number | null { return value; } +function resolveUsableRatio(override: number | null | undefined): number { + if (typeof override !== 'number' || !Number.isFinite(override) || override <= 0) { + return USABLE_CONTEXT_RATIO; + } + return Math.min(override, 1); +} + +function buildConfig(windowSize: number, ratio: number): ModelContextConfig { + return { + maxTokens: windowSize, + usableTokens: Math.floor(windowSize * ratio) + }; +} + function parseContextWindowSize(modelIdentifier: string): number | null { const delimitedMatch = /(?:\(|\[)\s*(\d+(?:[,_]\d+)*(?:\.\d+)?)\s*([km])\s*(?:\)|\])/i.exec(modelIdentifier); if (delimitedMatch) { @@ -73,32 +105,61 @@ export function getModelContextIdentifier(model?: string | ModelIdentifier): str 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 * USABLE_CONTEXT_RATIO) - }; +/** + * Resolves the `{maxTokens, usableTokens}` context window pair for a session. + * + * Priority for `maxTokens` (highest first): + * 1. `overrides.effectiveWindow` — Claude Code compaction window override + * (`CLAUDE_CODE_AUTO_COMPACT_WINDOW` env or `autoCompactWindow` settings.json). + * This shrinks the *effective* window CC will let the conversation grow into, + * so it takes precedence over the model's actual capacity. **Clamped to the + * native window** when a status-JSON / model-inferred native signal is + * available — mirrors Claude Code, which silently caps `autoCompactWindow` + * values larger than the model's actual capacity. + * 2. `contextWindowSize` — status JSON's reported `context_window_size`. + * 3. Window inferred from the model identifier (e.g. `[1m]` suffix). + * 4. `DEFAULT_CONTEXT_WINDOW_SIZE` (200k) for older / unknown models. + * + * `usableTokens` is `floor(maxTokens * ratio)` where `ratio` defaults to 0.8 + * but is replaced by `overrides.ratio` when set (`CLAUDE_AUTOCOMPACT_PCT_OVERRIDE` + * or `DISABLE_AUTO_COMPACT=1`). + */ +export function getContextConfig( + modelIdentifier?: string, + contextWindowSize?: number | null, + overrides?: ContextConfigOverrides +): ModelContextConfig { + const ratio = resolveUsableRatio(overrides?.ratio); + + const overrideWindow = toValidWindowSize(overrides?.effectiveWindow); + if (overrideWindow !== null) { + // Mirror Claude Code's cap: `autoCompactWindow > nativeWindow` is a + // no-op in CC. Concrete case — a user sets `autoCompactWindow: 300000` + // because they mostly run Opus 4.7 (1M native) and want to compact + // earlier than the default. When the same user switches to Sonnet 4.5 + // (200k native) for a different task, CC silently caps the threshold + // back to ~200k. Without this clamp the widget would compute + // `tokens / 240000` and under-report Ctx(u) by ~33% relative to what + // CC actually does. + const nativeWindow = toValidWindowSize(contextWindowSize) + ?? (modelIdentifier ? parseContextWindowSize(modelIdentifier) : null); + const effectiveWindow = nativeWindow !== null + ? Math.min(overrideWindow, nativeWindow) + : overrideWindow; + return buildConfig(effectiveWindow, ratio); } - // Default to 200k for older models - const defaultConfig = { - maxTokens: DEFAULT_CONTEXT_WINDOW_SIZE, - usableTokens: Math.floor(DEFAULT_CONTEXT_WINDOW_SIZE * USABLE_CONTEXT_RATIO) - }; - - if (!modelIdentifier) { - return defaultConfig; + const statusWindowSize = toValidWindowSize(contextWindowSize); + if (statusWindowSize !== null) { + return buildConfig(statusWindowSize, ratio); } - const inferredWindowSize = parseContextWindowSize(modelIdentifier); - if (inferredWindowSize !== null) { - return { - maxTokens: inferredWindowSize, - usableTokens: Math.floor(inferredWindowSize * USABLE_CONTEXT_RATIO) - }; + if (modelIdentifier) { + const inferredWindowSize = parseContextWindowSize(modelIdentifier); + if (inferredWindowSize !== null) { + return buildConfig(inferredWindowSize, ratio); + } } - return defaultConfig; + return buildConfig(DEFAULT_CONTEXT_WINDOW_SIZE, ratio); } diff --git a/src/widgets/ContextPercentageUsable.ts b/src/widgets/ContextPercentageUsable.ts index 9dd8c679..baf0fca2 100644 --- a/src/widgets/ContextPercentageUsable.ts +++ b/src/widgets/ContextPercentageUsable.ts @@ -6,6 +6,7 @@ import type { WidgetEditorDisplay, WidgetItem } from '../types/Widget'; +import { getCompactionOverrides } from '../utils/claude-compaction'; import { getContextWindowMetrics } from '../utils/context-window'; import { getContextConfig, @@ -28,7 +29,7 @@ import { formatRawOrLabeledValue } from './shared/raw-or-labeled'; export class ContextPercentageUsableWidget implements Widget { getDefaultColor(): string { return 'green'; } - getDescription(): string { return 'Shows percentage of usable context window used or remaining (80% of max before auto-compact)'; } + getDescription(): string { return 'Shows used/remaining percentage of usable context (80% of window, respects Claude Code\'s autoCompactWindow / compaction env vars)'; } getDisplayName(): string { return 'Context % (usable)'; } getCategory(): string { return 'Context'; } getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { @@ -55,7 +56,8 @@ export class ContextPercentageUsableWidget implements Widget { const sliderMode = getContextSliderMode(item); const modelIdentifier = getModelContextIdentifier(context.data?.model); const contextWindowMetrics = getContextWindowMetrics(context.data); - const contextConfig = getContextConfig(modelIdentifier, contextWindowMetrics.windowSize); + const compactionOverrides = getCompactionOverrides(); + const contextConfig = getContextConfig(modelIdentifier, contextWindowMetrics.windowSize, compactionOverrides); const formatContextPercentage = (displayPercentage: number): string => { const sliderResult = renderContextSlider(sliderMode, displayPercentage); diff --git a/src/widgets/__tests__/ContextPercentageUsable.test.ts b/src/widgets/__tests__/ContextPercentageUsable.test.ts index 77a9dc4a..87fe3e6d 100644 --- a/src/widgets/__tests__/ContextPercentageUsable.test.ts +++ b/src/widgets/__tests__/ContextPercentageUsable.test.ts @@ -1,4 +1,10 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; import { + afterAll, + beforeAll, + beforeEach, describe, expect, it @@ -11,6 +17,80 @@ import type { import { DEFAULT_SETTINGS } from '../../types/Settings'; import { ContextPercentageUsableWidget } from '../ContextPercentageUsable'; +// `ContextPercentageUsableWidget` resolves compaction overrides by reading +// env vars and walking the Claude Code settings.json layers under the user's +// real `~/.claude` and the process's real cwd. To keep this suite hermetic, +// redirect both to empty tmpdirs for the entire file's lifetime. +const ORIGINAL_ENV = { + CLAUDE_CODE_AUTO_COMPACT_WINDOW: process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW, + CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE, + DISABLE_AUTO_COMPACT: process.env.DISABLE_AUTO_COMPACT, + CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR +}; + +let isolatedConfigDir = ''; +let isolatedCwd = ''; +let originalCwd = ''; + +function clearCompactionEnv(): void { + delete process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW; + delete process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE; + delete process.env.DISABLE_AUTO_COMPACT; + delete process.env.CLAUDE_CONFIG_DIR; +} + +function applyIsolatedEnv(): void { + clearCompactionEnv(); + process.env.CLAUDE_CONFIG_DIR = isolatedConfigDir; + process.chdir(isolatedCwd); +} + +function restoreCompactionEnv(): void { + if (ORIGINAL_ENV.CLAUDE_CODE_AUTO_COMPACT_WINDOW === undefined) { + delete process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW; + } else { + process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = ORIGINAL_ENV.CLAUDE_CODE_AUTO_COMPACT_WINDOW; + } + if (ORIGINAL_ENV.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE === undefined) { + delete process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE; + } else { + process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE = ORIGINAL_ENV.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE; + } + if (ORIGINAL_ENV.DISABLE_AUTO_COMPACT === undefined) { + delete process.env.DISABLE_AUTO_COMPACT; + } else { + process.env.DISABLE_AUTO_COMPACT = ORIGINAL_ENV.DISABLE_AUTO_COMPACT; + } + if (ORIGINAL_ENV.CLAUDE_CONFIG_DIR === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = ORIGINAL_ENV.CLAUDE_CONFIG_DIR; + } +} + +beforeAll(() => { + isolatedConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-widget-config-')); + isolatedCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-widget-cwd-')); + originalCwd = process.cwd(); +}); + +afterAll(() => { + if (originalCwd) { + try { process.chdir(originalCwd); } catch { /* ignore */ } + } + if (isolatedConfigDir) { + fs.rmSync(isolatedConfigDir, { recursive: true, force: true }); + } + if (isolatedCwd) { + fs.rmSync(isolatedCwd, { recursive: true, force: true }); + } + restoreCompactionEnv(); +}); + +beforeEach(() => { + applyIsolatedEnv(); +}); + function render(modelId: string | undefined, contextLength: number, rawValue = false, inverse = false) { const widget = new ContextPercentageUsableWidget(); const context: RenderContext = { @@ -149,4 +229,37 @@ describe('ContextPercentageUsableWidget', () => { expect(result).toBe('Ctx(u) Used: 26.3%'); }); }); + + describe('Claude Code compaction overrides', () => { + // The top-level `beforeEach` already isolates env + cwd. Each individual + // test below only needs to set the override(s) it cares about. + it('respects CLAUDE_CODE_AUTO_COMPACT_WINDOW env to shrink the effective window', () => { + // Opus 1M → autoCompactWindow=200k → usable = 160k → 70k / 160k = 43.75%. + process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = '200000'; + const result = render('claude-opus-4-6[1m]', 70000); + expect(result).toBe('Ctx(u) Used: 43.8%'); + }); + + it('respects CLAUDE_AUTOCOMPACT_PCT_OVERRIDE env to change the usable ratio', () => { + // Sonnet 200k, ratio=0.6 → usable = 120k → 96k / 120k = 80%. + process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE = '60'; + const result = render('claude-sonnet-4-5-20250929', 96000); + expect(result).toBe('Ctx(u) Used: 80.0%'); + }); + + it('respects DISABLE_AUTO_COMPACT=1 env to treat the entire window as usable', () => { + // Sonnet 200k, ratio=1.0 → usable = 200k → 100k / 200k = 50%. + process.env.DISABLE_AUTO_COMPACT = '1'; + const result = render('claude-sonnet-4-5-20250929', 100000); + expect(result).toBe('Ctx(u) Used: 50.0%'); + }); + + it('combines window + ratio overrides', () => { + // Opus 1M → effective 200k, ratio 0.6 → usable = 120k → 60k / 120k = 50%. + process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = '200000'; + process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE = '60'; + const result = render('claude-opus-4-6[1m]', 60000); + expect(result).toBe('Ctx(u) Used: 50.0%'); + }); + }); });