Skip to content
Open
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
17 changes: 17 additions & 0 deletions src/ccstatusline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import type { StatusJSON } from './types/StatusJSON';
import { StatusJSONSchema } from './types/StatusJSON';
import { getVisibleText } from './utils/ansi';
import { updateColorMap } from './utils/colors';
import {
detectCompaction,
loadCompactionState,
saveCompactionState
} from './utils/compaction';
import {
initConfigPath,
loadSettings,
Expand Down Expand Up @@ -140,6 +145,17 @@ async function renderMultipleLines(data: StatusJSON) {
skillsMetrics = getSkillsMetrics(data.session_id);
}

// Compaction detection — track context percentage drops between renders
let compactionCount = 0;
const hasCompactionWidget = lines.some(line => line.some(item => item.type === 'compaction-counter'));
if (hasCompactionWidget && data.session_id && data.context_window?.used_percentage !== null && data.context_window?.used_percentage !== undefined) {
const prevState = loadCompactionState(data.session_id);
const ctxPct = Math.round(data.context_window.used_percentage);
const newState = detectCompaction(ctxPct, prevState);
saveCompactionState(data.session_id, newState);
compactionCount = newState.count;
}

// Create render context
const context: RenderContext = {
data,
Expand All @@ -149,6 +165,7 @@ async function renderMultipleLines(data: StatusJSON) {
usageData,
sessionDuration,
skillsMetrics,
compactionData: compactionCount > 0 ? { count: compactionCount } : null,
isPreview: false
};

Expand Down
5 changes: 5 additions & 0 deletions src/types/RenderContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ export interface RenderUsageData {
error?: 'no-credentials' | 'timeout' | 'rate-limited' | 'api-error' | 'parse-error';
}

export interface CompactionData {
count: number;
}

export interface RenderContext {
data?: StatusJSON;
tokenMetrics?: TokenMetrics | null;
Expand All @@ -28,6 +32,7 @@ export interface RenderContext {
sessionDuration?: string | null;
blockMetrics?: BlockMetrics | null;
skillsMetrics?: SkillsMetrics | null;
compactionData?: CompactionData | null;
terminalWidth?: number | null;
isPreview?: boolean;
lineIndex?: number; // Index of the current line being rendered (for theme cycling)
Expand Down
81 changes: 81 additions & 0 deletions src/utils/__tests__/compaction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {
describe,
expect,
it
} from 'vitest';

import {
detectCompaction,
type CompactionState
} from '../compaction';

const fresh: CompactionState = { count: 0, prevCtxPct: 0 };

describe('detectCompaction', () => {
it('does not detect on first render (no previous state)', () => {
const result = detectCompaction(40, fresh);
expect(result.count).toBe(0);
expect(result.prevCtxPct).toBe(40);
});

it('detects compaction when ctx drops by more than 2 points', () => {
const prev: CompactionState = { count: 0, prevCtxPct: 40 };
const result = detectCompaction(30, prev);
expect(result.count).toBe(1);
});

it('does not detect when ctx drops by exactly 2 points', () => {
const prev: CompactionState = { count: 0, prevCtxPct: 40 };
const result = detectCompaction(38, prev);
expect(result.count).toBe(0);
});

it('does not detect when ctx drops by 1 point (rounding noise)', () => {
const prev: CompactionState = { count: 0, prevCtxPct: 8 };
const result = detectCompaction(7, prev);
expect(result.count).toBe(0);
});

it('does not detect when ctx increases', () => {
const prev: CompactionState = { count: 0, prevCtxPct: 40 };
const result = detectCompaction(45, prev);
expect(result.count).toBe(0);
});

it('does not detect when ctx stays the same', () => {
const prev: CompactionState = { count: 0, prevCtxPct: 40 };
const result = detectCompaction(40, prev);
expect(result.count).toBe(0);
});

it('detects 3-point drop on 1M window', () => {
const prev: CompactionState = { count: 0, prevCtxPct: 8 };
const result = detectCompaction(5, prev);
expect(result.count).toBe(1);
});

it('detects large compaction on 200K window', () => {
const prev: CompactionState = { count: 0, prevCtxPct: 85 };
const result = detectCompaction(30, prev);
expect(result.count).toBe(1);
});

it('increments existing count', () => {
const prev: CompactionState = { count: 3, prevCtxPct: 70 };
const result = detectCompaction(40, prev);
expect(result.count).toBe(4);
});

it('updates prevCtxPct regardless of detection', () => {
const prev: CompactionState = { count: 0, prevCtxPct: 40 };
const result = detectCompaction(45, prev);
expect(result.prevCtxPct).toBe(45);
});

it('accepts custom threshold', () => {
const prev: CompactionState = { count: 0, prevCtxPct: 10 };
// 1-point drop with threshold=1 should detect
const result = detectCompaction(8, prev, 1);
expect(result.count).toBe(1);
});
});
85 changes: 85 additions & 0 deletions src/utils/compaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import {
existsSync,
mkdirSync,
readFileSync,
writeFileSync
} from 'node:fs';
import { join } from 'node:path';

import { z } from 'zod';

const DEFAULT_DROP_THRESHOLD = 2;
const CACHE_SUBDIR = 'compaction';

export interface CompactionState {
count: number;
prevCtxPct: number;
}

const CompactionStateSchema = z.object({
count: z.number().default(0),
prevCtxPct: z.number().default(0)
});

/**
* Detect context compaction events.
*
* Context only grows until compaction — any drop in used_percentage beyond
* the threshold indicates Claude Code compacted the conversation. The threshold
* filters rounding noise and cache accounting wobble (±1 point).
*
* @param currentCtxPct - Current used_percentage from StatusJSON
* @param state - Previous compaction state
* @param dropThreshold - Minimum percentage-point drop to count as compaction (default: 2)
* @returns Updated compaction state
*/
export function detectCompaction(
currentCtxPct: number,
state: CompactionState,
dropThreshold: number = DEFAULT_DROP_THRESHOLD
): CompactionState {
let { count } = state;
const { prevCtxPct } = state;

if (prevCtxPct > 0 && currentCtxPct < prevCtxPct - dropThreshold) {
count += 1;
}

return { count, prevCtxPct: currentCtxPct };
}

function getCacheDir(): string {
const home = process.env.HOME ?? process.env.USERPROFILE ?? '';
return join(home, '.cache', 'ccstatusline', CACHE_SUBDIR);
}

function getStatePath(sessionId: string): string {
return join(getCacheDir(), `compaction-${sessionId}.json`);
}

/**
* Load compaction state for a session.
*/
export function loadCompactionState(sessionId: string): CompactionState {
const path = getStatePath(sessionId);
if (!existsSync(path)) {
return { count: 0, prevCtxPct: 0 };
}
try {
const raw: unknown = JSON.parse(readFileSync(path, 'utf-8'));
return CompactionStateSchema.parse(raw);
} catch {
return { count: 0, prevCtxPct: 0 };
}
}

/**
* Save compaction state for a session.
*/
export function saveCompactionState(sessionId: string, state: CompactionState): void {
const dir = getCacheDir();
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(getStatePath(sessionId), JSON.stringify(state) + '\n');
}
3 changes: 2 additions & 1 deletion src/utils/widget-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ export const WIDGET_MANIFEST: WidgetManifestEntry[] = [
{ type: 'context-bar', create: () => new widgets.ContextBarWidget() },
{ type: 'skills', create: () => new widgets.SkillsWidget() },
{ type: 'thinking-effort', create: () => new widgets.ThinkingEffortWidget() },
{ type: 'vim-mode', create: () => new widgets.VimModeWidget() }
{ type: 'vim-mode', create: () => new widgets.VimModeWidget() },
{ type: 'compaction-counter', create: () => new widgets.CompactionCounterWidget() }
];

export const LAYOUT_WIDGET_MANIFEST: LayoutWidgetManifestEntry[] = [
Expand Down
40 changes: 40 additions & 0 deletions src/widgets/CompactionCounter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { RenderContext } from '../types/RenderContext';
import type { Settings } from '../types/Settings';
import type {
Widget,
WidgetEditorDisplay,
WidgetItem
} from '../types/Widget';

/**
* Displays a count of context compaction events in the current session.
*
* Claude Code periodically compacts (summarizes) conversation context when it
* approaches the context window limit. This widget tracks how many times
* compaction has occurred by detecting drops in used_percentage between renders.
*
* Hidden when count is 0. Shows ↻N when compactions have occurred.
*/
export class CompactionCounterWidget implements Widget {
getDefaultColor(): string { return 'yellow'; }
getDescription(): string { return 'Count of context compaction events in the current session. Hidden when no compactions have occurred.'; }
getDisplayName(): string { return 'Compaction Counter'; }
getCategory(): string { return 'Context'; }
getEditorDisplay(_item: WidgetItem): WidgetEditorDisplay {
return { displayText: 'Compaction Counter' };
}

render(_item: WidgetItem, context: RenderContext, _settings: Settings): string | null {
if (context.isPreview) {
return '\u21BB2';
}

const count = context.compactionData?.count;
if (!count) return null;

return `\u21BB${count}`;
}

supportsRawValue(): boolean { return false; }
supportsColors(_item: WidgetItem): boolean { return true; }
}
87 changes: 87 additions & 0 deletions src/widgets/__tests__/CompactionCounter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import {
describe,
expect,
it
} from 'vitest';

import type {
RenderContext,
WidgetItem
} from '../../types';
import { DEFAULT_SETTINGS } from '../../types/Settings';
import { CompactionCounterWidget } from '../CompactionCounter';

const ITEM: WidgetItem = { id: 'compaction-counter', type: 'compaction-counter' };

function makeContext(overrides: Partial<RenderContext> = {}): RenderContext {
return { ...overrides };
}

describe('CompactionCounterWidget', () => {
describe('metadata', () => {
it('has correct display name', () => {
expect(new CompactionCounterWidget().getDisplayName()).toBe('Compaction Counter');
});

it('has correct category', () => {
expect(new CompactionCounterWidget().getCategory()).toBe('Context');
});

it('does not support raw value', () => {
expect(new CompactionCounterWidget().supportsRawValue()).toBe(false);
});

it('supports colors', () => {
expect(new CompactionCounterWidget().supportsColors(ITEM)).toBe(true);
});

it('has correct default color', () => {
expect(new CompactionCounterWidget().getDefaultColor()).toBe('yellow');
});
});

describe('render()', () => {
it('renders compaction count with arrow', () => {
const ctx = makeContext({ compactionData: { count: 3 } });
expect(new CompactionCounterWidget().render(ITEM, ctx, DEFAULT_SETTINGS)).toBe('\u21BB3');
});

it('renders count of 1', () => {
const ctx = makeContext({ compactionData: { count: 1 } });
expect(new CompactionCounterWidget().render(ITEM, ctx, DEFAULT_SETTINGS)).toBe('\u21BB1');
});

it('returns null when count is 0', () => {
const ctx = makeContext({ compactionData: { count: 0 } });
expect(new CompactionCounterWidget().render(ITEM, ctx, DEFAULT_SETTINGS)).toBeNull();
});

it('returns null when compactionData is undefined', () => {
const ctx = makeContext();
expect(new CompactionCounterWidget().render(ITEM, ctx, DEFAULT_SETTINGS)).toBeNull();
});

it('returns null when compactionData is null', () => {
const ctx = makeContext({ compactionData: null });
expect(new CompactionCounterWidget().render(ITEM, ctx, DEFAULT_SETTINGS)).toBeNull();
});

it('returns null when context.data is absent', () => {
const ctx = makeContext();
expect(new CompactionCounterWidget().render(ITEM, ctx, DEFAULT_SETTINGS)).toBeNull();
});

it('returns sample data in preview mode', () => {
const ctx = makeContext({ isPreview: true });
expect(new CompactionCounterWidget().render(ITEM, ctx, DEFAULT_SETTINGS)).toBe('\u21BB2');
});
});

describe('editor', () => {
it('has correct editor display', () => {
expect(new CompactionCounterWidget().getEditorDisplay(ITEM)).toEqual({
displayText: 'Compaction Counter'
});
});
});
});
3 changes: 2 additions & 1 deletion src/widgets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,5 @@ export { ContextBarWidget } from './ContextBar';
export { LinkWidget } from './Link';
export { SkillsWidget } from './Skills';
export { ThinkingEffortWidget } from './ThinkingEffort';
export { VimModeWidget } from './VimMode';
export { VimModeWidget } from './VimMode';
export { CompactionCounterWidget } from './CompactionCounter';