Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,17 @@ Block Usage was previously named Session Usage. Its default label is now `5h:`,

An explicit per-model quota at 0% is shown even when its reset timestamp is still missing; the time cursor appears only once a usable window is available. Weekly Fable Usage uses the label `Weekly Fable:` and stays empty when the API reports no Fable quota.

### Usable context estimate

**Context % (usable)** estimates the budget available before auto-compaction. It uses 80% of the full context window by default. These Claude Code settings affect only this widget's usable budget; **Context Window**, **Context %**, and **Context Bar** continue to describe the full window.

- `CLAUDE_CODE_AUTO_COMPACT_WINDOW` sets the compaction window in tokens, ahead of Claude Code's top-level `autoCompactWindow` setting. Environment values accept numbers or `k`/`M` suffixes and are bounded to 100,000–1,000,000 tokens, then capped at the full model window. Suffixes require Claude Code 2.1.304 or later.
- Settings are read from Claude Code's user directory (`CLAUDE_CONFIG_DIR`, otherwise `~/.claude`) and the payload's project directory. Project-local settings override project settings, then user-local and user settings. The reader refreshes these values on each render and leaves malformed files untouched.
- `CLAUDE_AUTOCOMPACT_PCT_OVERRIDE` accepts a percentage from 1 through 100. It can lower the 80% estimate, but cannot raise it. For example, `60` with a 200,000-token compaction window gives a 120,000-token usable budget.
- `DISABLE_AUTO_COMPACT=1`, `DISABLE_COMPACT=1`, or Claude Code's `autoCompactEnabled: false` makes the widget use the full model window. The environment switches also accept `true`, `yes`, and `on` (case-insensitive).

The exact live compaction threshold is not included in the status payload. This remains an estimate: model-specific reserves, per-model `modelSettings`, managed settings, and CLI-only overrides may make Claude Code compact at a different point. See [Claude Code's compaction settings](https://code.claude.com/docs/en/settings-reference#autocompactwindow) and [environment variables](https://code.claude.com/docs/en/env-vars). The separate `CCSTATUSLINE_CONTEXT_WINDOW_SIZE` override changes the full window for every context widget and takes precedence over the reported size; its percentage is recalculated from token usage.

### Environment, Layout & Custom

- **Current Working Dir** / **Terminal Width** / **Memory Usage** - Show the current working directory, detected terminal width, and system memory usage. Current Working Dir can prepend an optional custom glyph, including when raw-value mode replaces the `cwd:` label. When combining home abbreviation with a segment limit, `~` is preserved without counting toward that limit. Press `l` in its editor to cycle clickable links (`off` → file manager → VS Code → Cursor). The displayed path still honors home abbreviation, segment limits, and fish-style shortening, while the link always targets the original absolute directory. File-manager links use `file://` URLs on POSIX and Windows, including UNC shares; local terminals need OSC 8 hyperlink support.
Expand Down
104 changes: 104 additions & 0 deletions src/utils/__tests__/auto-compact-context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import {
afterEach,
beforeEach,
describe,
expect,
it
} from 'vitest';

import { getContextConfig } from '../model-context';

const envKeys = [
'CLAUDE_AUTOCOMPACT_PCT_OVERRIDE',
'CLAUDE_CODE_AUTO_COMPACT_WINDOW',
'DISABLE_AUTO_COMPACT',
'DISABLE_COMPACT',
'CCSTATUSLINE_CONTEXT_WINDOW_SIZE',
'CCSTATUSLINE_CONTEXT_SIZE_FALLBACK'
];
const originalEnv = Object.fromEntries(envKeys.map(key => [key, process.env[key]]));

beforeEach(() => {
for (const key of envKeys) {
Reflect.deleteProperty(process.env, key);
}
});

afterEach(() => {
for (const key of envKeys) {
const value = originalEnv[key];
if (value === undefined) {
Reflect.deleteProperty(process.env, key);
} else {
process.env[key] = value;
}
}
});

describe('usable context budget', () => {
it('keeps the existing 80% estimate without overrides', () => {
expect(getContextConfig(undefined, 1000000)).toEqual({ maxTokens: 1000000, usableTokens: 800000 });
});

it('treats the configured compaction window as tokens without changing the full model window', () => {
expect(getContextConfig(undefined, 1000000, { windowSize: 200000 }))
.toEqual({ maxTokens: 1000000, usableTokens: 160000 });
});

it('prioritizes the environment window and applies a lower percentage to that window', () => {
process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = '500000';
process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE = '60';
expect(getContextConfig(undefined, 1000000, { windowSize: 200000 }))
.toEqual({ maxTokens: 1000000, usableTokens: 300000 });
});

it('never expands the compaction window past the effective full window', () => {
process.env.CCSTATUSLINE_CONTEXT_WINDOW_SIZE = '300k';
process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = '500000';
expect(getContextConfig(undefined, 200000)).toEqual({ maxTokens: 300000, usableTokens: 240000 });
});

it.each(['1', 'true', 'YES', 'on'])('uses the full window when auto-compaction is disabled with %s', (value) => {
process.env.DISABLE_AUTO_COMPACT = value;
process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = '200000';
process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE = '60';
expect(getContextConfig(undefined, 1000000, { windowSize: 100000, enabled: true }).usableTokens).toBe(1000000);
});

it('honors disabled compaction in settings and the all-compaction environment switch', () => {
process.env.DISABLE_AUTO_COMPACT = 'false';
expect(getContextConfig(undefined, 200000, { enabled: false }).usableTokens).toBe(200000);
process.env.DISABLE_COMPACT = '1';
expect(getContextConfig(undefined, 200000).usableTokens).toBe(200000);
});

it.each(['', 'nope', '60junk', '0', '-1', '0.5', '101', 'Infinity', '90', '100'])('does not raise the estimate for percentage %s', (value) => {
process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE = value;
expect(getContextConfig(undefined, 200000).usableTokens).toBe(160000);
});

it('reads environment changes without a permanent ratio cache', () => {
process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE = '60';
expect(getContextConfig(undefined, 200000).usableTokens).toBe(120000);
process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE = '50';
expect(getContextConfig(undefined, 200000).usableTokens).toBe(100000);
delete process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE;
expect(getContextConfig(undefined, 200000).usableTokens).toBe(160000);
});

it.each(['200000junk', '', '0', '-1', 'Infinity', 'NaN'])('falls back to settings when the environment window is invalid: %s', (value) => {
process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = value;
expect(getContextConfig(undefined, 1000000, { windowSize: 300000 }).usableTokens).toBe(240000);
});

it.each(['200', '1', '999', '0.1', '0.2k'])('clamps the small positive compaction window %s to 100k', (value) => {
process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = value;
expect(getContextConfig(undefined, 1000000, { windowSize: 300000 }))
.toEqual({ maxTokens: 1000000, usableTokens: 80000 });
});

it.each([['50k', 80000], ['500k', 400000], ['2M', 800000]])('bounds the environment window %s', (value, expected) => {
process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = value;
expect(getContextConfig(undefined, 2000000).usableTokens).toBe(expected);
});
});
67 changes: 67 additions & 0 deletions src/utils/__tests__/claude-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
CCSTATUSLINE_COMMANDS,
buildStatusLineCommand,
classifyInstallation,
getAutoCompactConfig,
getClaudeCodeVersion,
getClaudeJsonPath,
getClaudeSettingsPath,
Expand Down Expand Up @@ -94,6 +95,72 @@ afterAll(() => {
}
});

describe('getAutoCompactConfig', () => {
function writeLayer(relativePath: string, value: unknown): void {
const file = path.join(testClaudeConfigDir, relativePath);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify(value));
}

it('reads token counts from the overridden Claude config directory', () => {
writeLayer('settings.json', { autoCompactWindow: 200000, autoCompactEnabled: false });
expect(getAutoCompactConfig(path.join(testClaudeConfigDir, 'project'))).toEqual({ enabled: false, windowSize: 200000 });
});

it.each([true, false])('falls back to the legacy toggle %s without taking its window', (enabled) => {
writeLayer('.claude.json', { autoCompactEnabled: enabled, autoCompactWindow: 100000 });
writeLayer('settings.json', { autoCompactWindow: 300000 });
expect(getAutoCompactConfig(path.join(testClaudeConfigDir, 'project'))).toEqual({ enabled, windowSize: 300000 });
});

it('reads the legacy preference from the home directory without a config override', () => {
delete process.env.CLAUDE_CONFIG_DIR;
writeLayer('.claude.json', { autoCompactEnabled: false });
expect(getAutoCompactConfig(path.join(testClaudeConfigDir, 'project')).enabled).toBe(false);
});

it.each([true, false])('prioritizes an explicit settings toggle %s over the legacy preference', (enabled) => {
writeLayer('.claude.json', { autoCompactEnabled: !enabled });
writeLayer('settings.json', { autoCompactEnabled: enabled });
expect(getAutoCompactConfig(path.join(testClaudeConfigDir, 'project')).enabled).toBe(enabled);
});

it.each(['{ invalid JSON', 'null', '{"autoCompactEnabled":"false"}', '{}'])('ignores invalid legacy preferences: %s', (content) => {
fs.writeFileSync(getClaudeJsonPath(), content);
expect(getAutoCompactConfig(path.join(testClaudeConfigDir, 'project'))).toEqual({ enabled: undefined, windowSize: undefined });
});

it('merges each field independently with local settings above project and user settings', () => {
writeLayer('settings.json', { autoCompactWindow: 800000, autoCompactEnabled: false });
writeLayer('settings.local.json', { autoCompactWindow: 600000 });
writeLayer('project/.claude/settings.json', { autoCompactWindow: 400000, autoCompactEnabled: true });
writeLayer('project/.claude/settings.local.json', { autoCompactWindow: 200000 });
expect(getAutoCompactConfig(path.join(testClaudeConfigDir, 'project'))).toEqual({ enabled: true, windowSize: 200000 });
});

it('ignores malformed fields individually and never rewrites malformed JSON', () => {
writeLayer('settings.json', { autoCompactWindow: 500000, autoCompactEnabled: true });
writeLayer('project/.claude/settings.json', { autoCompactWindow: '60', autoCompactEnabled: false });
const localFile = path.join(testClaudeConfigDir, 'project/.claude/settings.local.json');
fs.writeFileSync(localFile, '{ invalid JSON');
expect(getAutoCompactConfig(path.join(testClaudeConfigDir, 'project'))).toEqual({ enabled: false, windowSize: 500000 });
expect(fs.readFileSync(localFile, 'utf-8')).toBe('{ invalid JSON');
});

it.each([80, 0, -1, 1000001, 200000.5, null, '200000'])('ignores an invalid window of %s', (window) => {
writeLayer('settings.json', { autoCompactWindow: window });
expect(getAutoCompactConfig(path.join(testClaudeConfigDir, 'project')).windowSize).toBeUndefined();
});

it('refreshes settings and does not leak values between projects', () => {
writeLayer('project/.claude/settings.json', { autoCompactWindow: 200000 });
expect(getAutoCompactConfig(path.join(testClaudeConfigDir, 'project')).windowSize).toBe(200000);
writeLayer('project/.claude/settings.json', { autoCompactWindow: 300000 });
expect(getAutoCompactConfig(path.join(testClaudeConfigDir, 'project')).windowSize).toBe(300000);
expect(getAutoCompactConfig(path.join(testClaudeConfigDir, 'other')).windowSize).toBeUndefined();
});
});

describe('isKnownCommand', () => {
it('should match exact NPM command', () => {
expect(isKnownCommand(CCSTATUSLINE_COMMANDS.NPM)).toBe(true);
Expand Down
39 changes: 39 additions & 0 deletions src/utils/claude-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,45 @@ export async function setRefreshInterval(interval: number | null): Promise<void>

const VoiceConfigSchema = z.object({ enabled: z.boolean().optional() });

const AutoCompactConfigSchema = z.object({
autoCompactEnabled: z.boolean().optional().catch(undefined),
autoCompactWindow: z.number().int().min(100000).max(1000000).optional().catch(undefined)
});

/** Read compaction fields independently so a layer can override just one field. */
export function getAutoCompactConfig(cwd: string = process.cwd()): { enabled?: boolean; windowSize?: number } {
let enabled: boolean | undefined;
let windowSize: number | undefined;
for (const filePath of getLayeredSettingsCandidatePathsByPriority(cwd)) {
try {
const raw: unknown = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
const parsed = AutoCompactConfigSchema.safeParse(raw);
if (parsed.success) {
enabled ??= parsed.data.autoCompactEnabled;
windowSize ??= parsed.data.autoCompactWindow;
}
} catch {
// Missing, unreadable and malformed settings contribute no overrides.
}
if (enabled !== undefined && windowSize !== undefined) {
break;
}
}
if (enabled === undefined) {
try {
// Older /config preferences live outside the layered settings files.
const raw: unknown = JSON.parse(fs.readFileSync(getClaudeJsonPath(), 'utf-8'));
const parsed = AutoCompactConfigSchema.safeParse(raw);
if (parsed.success) {
enabled = parsed.data.autoCompactEnabled;
}
} catch {
// Missing, unreadable and malformed legacy preferences add no override.
}
}
return { enabled, windowSize };
}

function getLayeredSettingsCandidatePathsByPriority(cwd: string): string[] {
const userDir = getClaudeConfigDir();
const projectDir = path.join(cwd, '.claude');
Expand Down
96 changes: 57 additions & 39 deletions src/utils/model-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ interface ModelIdentifier {
display_name?: string;
}

export interface AutoCompactConfig {
enabled?: boolean;
windowSize?: number;
}

const DEFAULT_CONTEXT_WINDOW_SIZE = 200000;
const USABLE_CONTEXT_RATIO = 0.8;
const CONTEXT_WINDOW_ENV_VAR = 'CCSTATUSLINE_CONTEXT_WINDOW_SIZE';
Expand Down Expand Up @@ -42,7 +47,7 @@ let cachedEnvRaw: string | undefined;
let cachedEnvResult: number | null = null;
let hasCachedEnv = false;

function parseContextWindowEnvValue(raw: string): number | null {
function parsePositiveWindowEnvValue(raw: string): number | null {
const trimmed = raw.trim();
if (!trimmed) {
return null;
Expand All @@ -61,7 +66,15 @@ function parseContextWindowEnvValue(raw: string): number | null {

const unit = suffixMatch[2]?.toLowerCase();
const multiplier = unit === 'm' ? 1000000 : unit === 'k' ? 1000 : 1;
const result = Math.round(numeric * multiplier);
return toValidWindowSize(numeric * multiplier);
}

function parseContextWindowEnvValue(raw: string): number | null {
const parsed = parsePositiveWindowEnvValue(raw);
if (parsed === null) {
return null;
}
const result = Math.round(parsed);

// Reject nonsensical tiny windows (e.g. "1.5" without a unit rounds to 2).
if (result < 1000) {
Expand All @@ -71,6 +84,12 @@ function parseContextWindowEnvValue(raw: string): number | null {
return result;
}

function parseAutoCompactWindowEnvValue(raw: string): number | null {
const parsed = parsePositiveWindowEnvValue(raw);
// Preserve small positive values so they reach Claude Code's minimum clamp.
return parsed === null ? null : Math.round(Math.max(100000, Math.min(1000000, parsed)));
}

export function getContextWindowEnvOverride(): number | null {
const raw = process.env[CONTEXT_WINDOW_ENV_VAR];
if (hasCachedEnv && raw === cachedEnvRaw) {
Expand Down Expand Up @@ -137,43 +156,42 @@ export function getModelContextIdentifier(model?: string | ModelIdentifier): str
return id ?? displayName;
}

export function getContextConfig(modelIdentifier?: string, contextWindowSize?: number | null): ModelContextConfig {
const envOverride = getContextWindowEnvOverride();
if (envOverride !== null) {
return {
maxTokens: envOverride,
usableTokens: Math.floor(envOverride * USABLE_CONTEXT_RATIO)
};
}

const statusWindowSize = toValidWindowSize(contextWindowSize);
if (statusWindowSize !== null) {
return {
maxTokens: statusWindowSize,
usableTokens: Math.floor(statusWindowSize * USABLE_CONTEXT_RATIO)
};
}

// Last-resort fallback when neither the live status window size nor a
// model-name hint is available. Defaults to 200k, overridable via
// CCSTATUSLINE_CONTEXT_SIZE_FALLBACK.
const fallbackWindowSize = getFallbackContextWindowSize();
const defaultConfig = {
maxTokens: fallbackWindowSize,
usableTokens: Math.floor(fallbackWindowSize * USABLE_CONTEXT_RATIO)
};

if (!modelIdentifier) {
return defaultConfig;
}
function isEnabledEnv(value: string | undefined): boolean {
return /^(1|true|yes|on)$/i.test(value?.trim() ?? '');
}

const inferredWindowSize = parseContextWindowSize(modelIdentifier);
if (inferredWindowSize !== null) {
return {
maxTokens: inferredWindowSize,
usableTokens: Math.floor(inferredWindowSize * USABLE_CONTEXT_RATIO)
};
}
function getUsableTokens(maxTokens: number, autoCompact: AutoCompactConfig): number {
if (autoCompact.enabled === false
|| isEnabledEnv(process.env.DISABLE_AUTO_COMPACT)
|| isEnabledEnv(process.env.DISABLE_COMPACT)) {
return maxTokens;
}

const rawWindow = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW;
const envWindow = rawWindow === undefined ? null : parseAutoCompactWindowEnvValue(rawWindow);
// Claude Code bounds its compaction window to 100k–1M and the model window.
const configuredWindow = envWindow ?? toValidWindowSize(autoCompact.windowSize);
const windowSize = Math.min(maxTokens, configuredWindow ?? maxTokens);

const rawPercent = process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE;
const percent = rawPercent === undefined ? NaN : Number(rawPercent.replace(/_/g, ''));
// The live trigger isn't in the status payload. Retain the existing 80%
// estimate; an explicit percentage can lower it, never raise it.
const ratio = Number.isFinite(percent) && percent >= 1 && percent <= 100
? Math.min(USABLE_CONTEXT_RATIO, percent / 100)
: USABLE_CONTEXT_RATIO;
return Math.max(1, Math.floor(windowSize * ratio));
}

return defaultConfig;
export function getContextConfig(
modelIdentifier?: string,
contextWindowSize?: number | null,
autoCompact: AutoCompactConfig = {}
): ModelContextConfig {
const maxTokens = getContextWindowEnvOverride()
?? toValidWindowSize(contextWindowSize)
?? (modelIdentifier ? parseContextWindowSize(modelIdentifier) : null)
?? getFallbackContextWindowSize();

return { maxTokens, usableTokens: getUsableTokens(maxTokens, autoCompact) };
}
Loading
Loading