diff --git a/src/ccstatusline.ts b/src/ccstatusline.ts index 54b70113..877072cb 100644 --- a/src/ccstatusline.ts +++ b/src/ccstatusline.ts @@ -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, @@ -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, @@ -149,6 +165,7 @@ async function renderMultipleLines(data: StatusJSON) { usageData, sessionDuration, skillsMetrics, + compactionData: compactionCount > 0 ? { count: compactionCount } : null, isPreview: false }; diff --git a/src/types/RenderContext.ts b/src/types/RenderContext.ts index 9ba86aea..0d0424ed 100644 --- a/src/types/RenderContext.ts +++ b/src/types/RenderContext.ts @@ -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; @@ -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) diff --git a/src/utils/__tests__/compaction.test.ts b/src/utils/__tests__/compaction.test.ts new file mode 100644 index 00000000..2f06802d --- /dev/null +++ b/src/utils/__tests__/compaction.test.ts @@ -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); + }); +}); diff --git a/src/utils/compaction.ts b/src/utils/compaction.ts new file mode 100644 index 00000000..281087e1 --- /dev/null +++ b/src/utils/compaction.ts @@ -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'); +} \ No newline at end of file diff --git a/src/utils/widget-manifest.ts b/src/utils/widget-manifest.ts index 890abd9f..ce02a1d2 100644 --- a/src/utils/widget-manifest.ts +++ b/src/utils/widget-manifest.ts @@ -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[] = [ diff --git a/src/widgets/CompactionCounter.ts b/src/widgets/CompactionCounter.ts new file mode 100644 index 00000000..60cfaf13 --- /dev/null +++ b/src/widgets/CompactionCounter.ts @@ -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; } +} diff --git a/src/widgets/__tests__/CompactionCounter.test.ts b/src/widgets/__tests__/CompactionCounter.test.ts new file mode 100644 index 00000000..0c70b482 --- /dev/null +++ b/src/widgets/__tests__/CompactionCounter.test.ts @@ -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 { + 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' + }); + }); + }); +}); diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 74d92bce..8220f4cc 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -35,4 +35,5 @@ export { ContextBarWidget } from './ContextBar'; export { LinkWidget } from './Link'; export { SkillsWidget } from './Skills'; export { ThinkingEffortWidget } from './ThinkingEffort'; -export { VimModeWidget } from './VimMode'; \ No newline at end of file +export { VimModeWidget } from './VimMode'; +export { CompactionCounterWidget } from './CompactionCounter'; \ No newline at end of file