diff --git a/README.md b/README.md
index e7ce83c1..ec22e135 100644
--- a/README.md
+++ b/README.md
@@ -362,6 +362,25 @@ For pinned installs, launch the TUI with `npx -y ccstatusline@latest` or `bunx -
+
+Usage Tracker (opt-in usage logging)
+
+The Usage Tracker records your subscription rate limit usage over time so it can be analyzed later. It is **off by default**; enable it in the TUI under **📊 Usage Tracker**.
+
+When enabled, every distinct rate limit observation is appended as one JSON line to:
+
+```
+$XDG_DATA_HOME/ccstatusline/usage-log.jsonl # or ~/.local/share/ccstatusline/usage-log.jsonl
+```
+
+Each record holds the rate limit percentages and reset timestamps exactly as Claude Code (or the Anthropic usage API) reported them, plus a hashed account id so records from different accounts can be told apart. **Tokens, credentials, prompts, and transcript contents are never written.** Heartbeat records mark stretches without new observations, so gaps in the data are unambiguous.
+
+The log is size bound: once it passes the configured rotation size (default 5 MB), it is rotated to `usage-log.1.jsonl` and only that one previous file is kept.
+
+> ⚠️ **API logging:** the tracker also logs the Anthropic usage API responses by default. If you have no usage widgets configured, that starts polling the usage API (~1 request every 3 minutes across all your sessions) where previously there were none. Turn **API Usage Logging** off to log only what Claude Code already sends.
+
+
+
## 🤝 Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
diff --git a/src/ccstatusline.ts b/src/ccstatusline.ts
index be492891..5c275484 100644
--- a/src/ccstatusline.ts
+++ b/src/ccstatusline.ts
@@ -50,6 +50,10 @@ import {
getPackageVersion,
getTerminalWidth
} from './utils/terminal';
+import {
+ initUsageLog,
+ logStdinRateLimits
+} from './utils/usage-log';
import { prefetchUsageDataIfNeeded } from './utils/usage-prefetch';
function hasSessionDurationInStatusJson(data: StatusJSON): boolean {
@@ -99,7 +103,18 @@ async function ensureWindowsUtf8CodePage() {
}
}
-async function renderMultipleLines(data: StatusJSON) {
+// Reads rate_limits off the raw, pre-zod stdin payload: StatusJSONSchema
+// declares rate_limits as a strict object, so the parsed data has unknown
+// buckets stripped - the usage log must keep them
+function getRawRateLimits(rawInput: unknown): unknown {
+ if (typeof rawInput !== 'object' || rawInput === null) {
+ return undefined;
+ }
+
+ return (rawInput as Record).rate_limits;
+}
+
+async function renderMultipleLines(data: StatusJSON, rawInput?: unknown) {
const settings = await loadSettings();
const configError = getConfigLoadError();
@@ -109,6 +124,15 @@ async function renderMultipleLines(data: StatusJSON) {
// Update color map after setting chalk level
updateColorMap();
+ // Usage Tracker: runs before prefetchUsageDataIfNeeded so a slow or failed
+ // API fetch cannot delay the stdin record and so the api-path hook inside
+ // fetchUsageData sees an initialized logger. Only call site of initUsageLog.
+ initUsageLog(settings.usageTracker, {
+ sessionId: data.session_id,
+ modelId: typeof data.model === 'string' ? data.model : data.model?.id
+ });
+ logStdinRateLimits(getRawRateLimits(rawInput));
+
// Get all lines to render
const lines = settings.lines;
@@ -136,7 +160,7 @@ async function renderMultipleLines(data: StatusJSON) {
sessionDuration = await getSessionDuration(data.transcript_path);
}
- const usageData = await prefetchUsageDataIfNeeded(lines, data);
+ const usageData = await prefetchUsageDataIfNeeded(lines, data, { forceUsageFetch: settings.usageTracker.enabled && settings.usageTracker.logApiUsage });
let speedMetrics: SpeedMetrics | null = null;
let windowedSpeedMetrics: Record | null = null;
@@ -328,14 +352,16 @@ async function main() {
const input = await readStdin();
if (input && input.trim() !== '') {
try {
- // Parse and validate JSON in one step
- const result = StatusJSONSchema.safeParse(JSON.parse(input));
+ // Keep the raw parse result: the usage log needs rate_limits
+ // before zod strips unknown buckets (see getRawRateLimits)
+ const rawInput: unknown = JSON.parse(input);
+ const result = StatusJSONSchema.safeParse(rawInput);
if (!result.success) {
console.error('Invalid status JSON format:', result.error.message);
process.exit(1);
}
- await renderMultipleLines(result.data);
+ await renderMultipleLines(result.data, rawInput);
} catch (error) {
console.error('Error parsing JSON:', error);
process.exit(1);
diff --git a/src/tui/App.tsx b/src/tui/App.tsx
index 53973ee4..a0d85598 100644
--- a/src/tui/App.tsx
+++ b/src/tui/App.tsx
@@ -93,7 +93,10 @@ import {
TerminalWidthMenu,
UninstallMenu,
UpdateCheckerMenu,
+ UsageTrackerMenu,
+ buildMainMenuItems,
getMainMenuInstallSelectionIndex,
+ getMainMenuSelectionIndex,
type InstallSelection,
type MainMenuOption,
type UninstallSelection,
@@ -127,6 +130,7 @@ type AppScreen = 'main'
| 'uninstallOptions'
| 'updates'
| 'refreshInterval'
+ | 'usageTracker'
| 'exportConfig'
| 'importConfig'
| 'importPreview';
@@ -1011,6 +1015,9 @@ export const App: React.FC = () => {
case 'configureStatusLine':
setScreen('refreshInterval');
break;
+ case 'usageTracker':
+ setScreen('usageTracker');
+ break;
case 'exportConfig':
setScreen('exportConfig');
break;
@@ -1395,6 +1402,24 @@ export const App: React.FC = () => {
}}
/>
)}
+ {screen === 'usageTracker' && (
+ {
+ setSettings(updatedSettings);
+ }}
+ onBack={() => {
+ setMenuSelections(prev => ({
+ ...prev,
+ main: getMainMenuSelectionIndex(
+ buildMainMenuItems(isClaudeInstalled, hasChanges, effectiveInstallation),
+ 'usageTracker'
+ )
+ }));
+ setScreen('main');
+ }}
+ />
+ )}
{screen === 'powerline' && (
{
'terminalConfig',
'globalOverrides',
'configureStatusLine',
+ 'usageTracker',
'-',
'exportConfig',
'importConfig',
@@ -214,6 +215,7 @@ describe('Main menu structure', () => {
'terminalConfig',
'globalOverrides',
'configureStatusLine',
+ 'usageTracker',
'-',
'exportConfig',
'importConfig',
@@ -240,6 +242,7 @@ describe('Main menu structure', () => {
'terminalConfig',
'globalOverrides',
'configureStatusLine',
+ 'usageTracker',
'-',
'exportConfig',
'importConfig',
@@ -274,14 +277,14 @@ describe('Main menu structure', () => {
sublabel: '(install first)'
}));
expect(buildManageInstallationItems()[0]).toEqual(expect.objectContaining({ label: '🔄 Check for Updates' }));
- expect(getMainMenuInstallSelectionIndex(false)).toBe(7);
- expect(getMainMenuInstallSelectionIndex(true, autoInstallation)).toBe(8);
- expect(getMainMenuInstallSelectionIndex(true, pinnedInstallation)).toBe(8);
- expect(getMainMenuSelectionIndex(buildMainMenuItems(true, false, autoInstallation), 'install')).toBe(8);
+ expect(getMainMenuInstallSelectionIndex(false)).toBe(8);
+ expect(getMainMenuInstallSelectionIndex(true, autoInstallation)).toBe(9);
+ expect(getMainMenuInstallSelectionIndex(true, pinnedInstallation)).toBe(9);
+ expect(getMainMenuSelectionIndex(buildMainMenuItems(true, false, autoInstallation), 'install')).toBe(9);
expect(getMainMenuSelectionIndex(
buildMainMenuItems(true, false, pinnedInstallation),
'manageInstallation'
- )).toBe(8);
+ )).toBe(9);
});
});
diff --git a/src/tui/components/MainMenu.tsx b/src/tui/components/MainMenu.tsx
index b3f9813a..1b76c949 100644
--- a/src/tui/components/MainMenu.tsx
+++ b/src/tui/components/MainMenu.tsx
@@ -21,6 +21,7 @@ export type MainMenuOption = 'lines'
| 'manageInstallation'
| 'checkUpdates'
| 'configureStatusLine'
+ | 'usageTracker'
| 'exportConfig'
| 'importConfig'
| 'starGithub'
@@ -122,6 +123,11 @@ export function buildMainMenuItems(
value: 'configureStatusLine',
description: 'Configure Claude Code status line settings like refresh interval'
},
+ {
+ label: '📊 Usage Tracker',
+ value: 'usageTracker',
+ description: 'Record rate limit usage to a local log file for later analysis'
+ },
'-',
{
label: '📤 Export Config',
diff --git a/src/tui/components/UsageTrackerMenu.tsx b/src/tui/components/UsageTrackerMenu.tsx
new file mode 100644
index 00000000..7e801be1
--- /dev/null
+++ b/src/tui/components/UsageTrackerMenu.tsx
@@ -0,0 +1,268 @@
+import {
+ Box,
+ Text,
+ useInput
+} from 'ink';
+import React, { useState } from 'react';
+
+import type {
+ Settings,
+ UsageTrackerConfig
+} from '../../types/Settings';
+import { shouldInsertInput } from '../../utils/input-guards';
+import { getResolvedLogPath } from '../../utils/usage-log';
+import { hasUsageDependentWidgets } from '../../utils/usage-prefetch';
+
+import {
+ List,
+ type ListEntry
+} from './List';
+
+type UsageTrackerValue = 'enabled' | 'logApiUsage' | 'heartbeatMinutes' | 'rotateMaxMb';
+
+export const API_POLLING_WARNING
+ = '⚠ No usage widgets are configured - enabling API logging will start polling the Anthropic usage API '
+ + '(~1 request / 3 min across all sessions).';
+
+export function shouldWarnAboutApiPolling(config: UsageTrackerConfig, lines: Settings['lines']): boolean {
+ return config.enabled && config.logApiUsage && !hasUsageDependentWidgets(lines);
+}
+
+export function validateHeartbeatMinutesInput(value: string): string | null {
+ const parsed = parseInt(value, 10);
+
+ if (value === '' || isNaN(parsed)) {
+ return 'Please enter a valid number';
+ }
+
+ if (parsed < 1) {
+ return `Minimum heartbeat interval is 1 min (you entered ${parsed} min)`;
+ }
+
+ if (parsed > 120) {
+ return `Maximum heartbeat interval is 120 min (you entered ${parsed} min)`;
+ }
+
+ return null;
+}
+
+export function validateRotateMaxMbInput(value: string): string | null {
+ const parsed = parseInt(value, 10);
+
+ if (value === '' || isNaN(parsed)) {
+ return 'Please enter a valid number';
+ }
+
+ if (parsed < 1) {
+ return `Minimum log size is 1 MB (you entered ${parsed} MB)`;
+ }
+
+ if (parsed > 100) {
+ return `Maximum log size is 100 MB (you entered ${parsed} MB)`;
+ }
+
+ return null;
+}
+
+export function buildUsageTrackerItems(config: UsageTrackerConfig): ListEntry[] {
+ return [
+ {
+ label: '📊 Usage Tracking',
+ sublabel: config.enabled ? '(enabled)' : '(disabled)',
+ value: 'enabled',
+ description: 'Append every distinct rate limit observation to a JSONL log file for later analysis.\nOnly usage percentages, timestamps and a hashed account id are written - never tokens or credentials.'
+ },
+ {
+ label: '🌐 API Usage Logging',
+ sublabel: config.logApiUsage ? '(on)' : '(off)',
+ disabled: !config.enabled,
+ value: 'logApiUsage',
+ description: 'Also log the Anthropic usage API responses, which carry more detail than the status line payload.\nTurn this off to log only what Claude Code already sends, without causing any API request.'
+ },
+ {
+ label: '💓 Heartbeat Interval',
+ sublabel: `(${config.heartbeatMinutes} min)`,
+ disabled: !config.enabled,
+ value: 'heartbeatMinutes',
+ description: 'How long the log can stay quiet before a heartbeat record is appended. Heartbeats make gaps unambiguous:\nthey distinguish "no usage" from "ccstatusline was not running". Enter 1-120 minutes.'
+ },
+ {
+ label: '♻️ Log Rotation Size',
+ sublabel: `(${config.rotateMaxMb} MB)`,
+ disabled: !config.enabled,
+ value: 'rotateMaxMb',
+ description: 'Maximum log size before it is rotated to a .1.jsonl file. One rotated file is kept, so disk usage stays\nbounded at roughly twice this value. Enter 1-100 MB.'
+ }
+ ];
+}
+
+export interface UsageTrackerMenuProps {
+ settings: Settings;
+ onUpdate: (settings: Settings) => void;
+ onBack: () => void;
+}
+
+export const UsageTrackerMenu: React.FC = ({
+ settings,
+ onUpdate,
+ onBack
+}) => {
+ const config = settings.usageTracker;
+ const [editingHeartbeat, setEditingHeartbeat] = useState(false);
+ const [editingRotateSize, setEditingRotateSize] = useState(false);
+ const [heartbeatInput, setHeartbeatInput] = useState(() => String(config.heartbeatMinutes));
+ const [rotateSizeInput, setRotateSizeInput] = useState(() => String(config.rotateMaxMb));
+ const [validationError, setValidationError] = useState(null);
+
+ const updateConfig = (changes: Partial) => {
+ onUpdate({
+ ...settings,
+ usageTracker: {
+ ...config,
+ ...changes
+ }
+ });
+ };
+
+ const handleSelect = (value: UsageTrackerValue | 'back') => {
+ switch (value) {
+ case 'back':
+ onBack();
+ break;
+ case 'enabled':
+ updateConfig({ enabled: !config.enabled });
+ break;
+ case 'logApiUsage':
+ updateConfig({ logApiUsage: !config.logApiUsage });
+ break;
+ case 'heartbeatMinutes':
+ setHeartbeatInput(String(config.heartbeatMinutes));
+ setEditingHeartbeat(true);
+ break;
+ case 'rotateMaxMb':
+ setRotateSizeInput(String(config.rotateMaxMb));
+ setEditingRotateSize(true);
+ break;
+ }
+ };
+
+ useInput((input, key) => {
+ if (editingHeartbeat) {
+ if (key.return) {
+ const error = validateHeartbeatMinutesInput(heartbeatInput);
+
+ if (error) {
+ setValidationError(error);
+ } else {
+ updateConfig({ heartbeatMinutes: parseInt(heartbeatInput, 10) });
+ setEditingHeartbeat(false);
+ setValidationError(null);
+ }
+ } else if (key.escape) {
+ setHeartbeatInput(String(config.heartbeatMinutes));
+ setEditingHeartbeat(false);
+ setValidationError(null);
+ } else if (key.backspace) {
+ setHeartbeatInput(heartbeatInput.slice(0, -1));
+ setValidationError(null);
+ } else if (key.delete) {
+ // No cursor position in simple input
+ } else if (shouldInsertInput(input, key) && /\d/.test(input)) {
+ const newValue = heartbeatInput + input;
+ if (newValue.length <= 3) {
+ setHeartbeatInput(newValue);
+ setValidationError(null);
+ }
+ }
+ return;
+ }
+
+ if (editingRotateSize) {
+ if (key.return) {
+ const error = validateRotateMaxMbInput(rotateSizeInput);
+
+ if (error) {
+ setValidationError(error);
+ } else {
+ updateConfig({ rotateMaxMb: parseInt(rotateSizeInput, 10) });
+ setEditingRotateSize(false);
+ setValidationError(null);
+ }
+ } else if (key.escape) {
+ setRotateSizeInput(String(config.rotateMaxMb));
+ setEditingRotateSize(false);
+ setValidationError(null);
+ } else if (key.backspace) {
+ setRotateSizeInput(rotateSizeInput.slice(0, -1));
+ setValidationError(null);
+ } else if (key.delete) {
+ // No cursor position in simple input
+ } else if (shouldInsertInput(input, key) && /\d/.test(input)) {
+ const newValue = rotateSizeInput + input;
+ if (newValue.length <= 3) {
+ setRotateSizeInput(newValue);
+ setValidationError(null);
+ }
+ }
+ return;
+ }
+
+ if (key.escape) {
+ onBack();
+ }
+ });
+
+ return (
+
+ Usage Tracker
+ Record rate limit usage to a local log file for later analysis
+
+ {editingHeartbeat ? (
+
+
+ Enter heartbeat interval in minutes (1-120):
+ {' '}
+ {heartbeatInput}
+
+ {validationError ? (
+ {validationError}
+ ) : (
+ Press Enter to confirm, ESC to cancel.
+ )}
+
+ ) : editingRotateSize ? (
+
+
+ Enter maximum log size in MB (1-100):
+ {' '}
+ {rotateSizeInput}
+
+ {validationError ? (
+ {validationError}
+ ) : (
+ Press Enter to confirm, ESC to cancel.
+ )}
+
+ ) : (
+ <>
+
+
+
+ Log file:
+ {' '}
+ {getResolvedLogPath(config)}
+
+ {shouldWarnAboutApiPolling(config, settings.lines) && (
+ {API_POLLING_WARNING}
+ )}
+
+ >
+ )}
+
+ );
+};
diff --git a/src/tui/components/__tests__/UsageTrackerMenu.test.ts b/src/tui/components/__tests__/UsageTrackerMenu.test.ts
new file mode 100644
index 00000000..d6eb945a
--- /dev/null
+++ b/src/tui/components/__tests__/UsageTrackerMenu.test.ts
@@ -0,0 +1,204 @@
+import { render } from 'ink';
+import { PassThrough } from 'node:stream';
+import React from 'react';
+import {
+ describe,
+ expect,
+ it,
+ vi
+} from 'vitest';
+
+import {
+ SettingsSchema,
+ type Settings,
+ type UsageTrackerConfig
+} from '../../../types/Settings';
+import {
+ UsageTrackerMenu,
+ buildUsageTrackerItems,
+ shouldWarnAboutApiPolling,
+ validateHeartbeatMinutesInput,
+ validateRotateMaxMbInput
+} from '../UsageTrackerMenu';
+
+class MockTtyStream extends PassThrough {
+ isTTY = true;
+ columns = 120;
+ rows = 40;
+
+ setRawMode() {
+ return this;
+ }
+
+ ref() {
+ return this;
+ }
+
+ unref() {
+ return this;
+ }
+}
+
+interface CapturedWriteStream extends NodeJS.WriteStream { getOutput: () => string }
+
+function createMockStdin(): NodeJS.ReadStream {
+ return new MockTtyStream() as unknown as NodeJS.ReadStream;
+}
+
+function createMockStdout(): CapturedWriteStream {
+ const stream = new MockTtyStream();
+ const chunks: string[] = [];
+
+ stream.on('data', (chunk: Buffer | string) => {
+ chunks.push(chunk.toString());
+ });
+
+ return Object.assign(stream as unknown as NodeJS.WriteStream, {
+ getOutput() {
+ return chunks.join('');
+ }
+ });
+}
+
+function flushInk() {
+ return new Promise((resolve) => {
+ setTimeout(resolve, 25);
+ });
+}
+
+const BASE_CONFIG: UsageTrackerConfig = {
+ enabled: true,
+ logApiUsage: true,
+ heartbeatMinutes: 10,
+ rotateMaxMb: 5
+};
+
+const USAGE_LINES: Settings['lines'] = [[{ id: '1', type: 'session-usage' }]];
+const PLAIN_LINES: Settings['lines'] = [[{ id: '1', type: 'model' }]];
+
+describe('validateHeartbeatMinutesInput', () => {
+ it('should accept valid values within range', () => {
+ expect(validateHeartbeatMinutesInput('1')).toBeNull();
+ expect(validateHeartbeatMinutesInput('10')).toBeNull();
+ expect(validateHeartbeatMinutesInput('120')).toBeNull();
+ });
+
+ it('should reject values outside the range', () => {
+ expect(validateHeartbeatMinutesInput('0')).toContain('Minimum');
+ expect(validateHeartbeatMinutesInput('121')).toContain('Maximum');
+ });
+
+ it('should reject empty and non-numeric input', () => {
+ expect(validateHeartbeatMinutesInput('')).toContain('valid number');
+ expect(validateHeartbeatMinutesInput('abc')).toContain('valid number');
+ });
+});
+
+describe('validateRotateMaxMbInput', () => {
+ it('should accept valid values within range', () => {
+ expect(validateRotateMaxMbInput('1')).toBeNull();
+ expect(validateRotateMaxMbInput('5')).toBeNull();
+ expect(validateRotateMaxMbInput('100')).toBeNull();
+ });
+
+ it('should reject values outside the range', () => {
+ expect(validateRotateMaxMbInput('0')).toContain('Minimum');
+ expect(validateRotateMaxMbInput('101')).toContain('Maximum');
+ });
+
+ it('should reject empty and non-numeric input', () => {
+ expect(validateRotateMaxMbInput('')).toContain('valid number');
+ expect(validateRotateMaxMbInput('abc')).toContain('valid number');
+ });
+});
+
+describe('buildUsageTrackerItems', () => {
+ it('shows the current values as sublabels', () => {
+ const items = buildUsageTrackerItems(BASE_CONFIG);
+
+ expect(items).toHaveLength(4);
+ expect(items[0]).toMatchObject({ value: 'enabled', sublabel: '(enabled)' });
+ expect(items[1]).toMatchObject({ value: 'logApiUsage', sublabel: '(on)' });
+ expect(items[2]?.sublabel).toBe('(10 min)');
+ expect(items[3]?.sublabel).toBe('(5 MB)');
+ });
+
+ it('disables the dependent options while tracking is off', () => {
+ const items = buildUsageTrackerItems({
+ ...BASE_CONFIG,
+ enabled: false,
+ logApiUsage: false
+ });
+
+ expect(items[0]?.sublabel).toBe('(disabled)');
+ expect(items[0]?.disabled).toBeFalsy();
+ expect(items[1]).toMatchObject({ sublabel: '(off)', disabled: true });
+ expect(items[2]?.disabled).toBe(true);
+ expect(items[3]?.disabled).toBe(true);
+ });
+});
+
+describe('shouldWarnAboutApiPolling', () => {
+ it('warns when API logging would poll for a user with no usage widgets', () => {
+ expect(shouldWarnAboutApiPolling(BASE_CONFIG, PLAIN_LINES)).toBe(true);
+ });
+
+ it('stays quiet when a usage widget already causes the polling', () => {
+ expect(shouldWarnAboutApiPolling(BASE_CONFIG, USAGE_LINES)).toBe(false);
+ });
+
+ it('stays quiet when no API request can happen', () => {
+ expect(shouldWarnAboutApiPolling({ ...BASE_CONFIG, logApiUsage: false }, PLAIN_LINES)).toBe(false);
+ expect(shouldWarnAboutApiPolling({ ...BASE_CONFIG, enabled: false }, PLAIN_LINES)).toBe(false);
+ });
+});
+
+describe('UsageTrackerMenu', () => {
+ it('shows the resolved log path, the API polling disclosure, and toggles tracking', async () => {
+ const stdin = createMockStdin();
+ const stdout = createMockStdout();
+ const stderr = createMockStdout();
+ const onUpdate = vi.fn();
+ const settings = SettingsSchema.parse({
+ lines: PLAIN_LINES,
+ usageTracker: BASE_CONFIG
+ });
+ const instance = render(
+ React.createElement(UsageTrackerMenu, {
+ settings,
+ onUpdate,
+ onBack: vi.fn()
+ }),
+ {
+ stdin,
+ stdout,
+ stderr,
+ debug: true,
+ exitOnCtrlC: false,
+ patchConsole: false
+ }
+ );
+
+ try {
+ await flushInk();
+
+ expect(stdout.getOutput()).toContain('usage-log.jsonl');
+ expect(stdout.getOutput()).toContain('No usage widgets are configured');
+
+ stdin.write('\r');
+ await flushInk();
+
+ const updated = onUpdate.mock.calls[0]?.[0] as Settings | undefined;
+ expect(updated?.usageTracker).toMatchObject({
+ enabled: false,
+ logApiUsage: true
+ });
+ } finally {
+ instance.unmount();
+ instance.cleanup();
+ stdin.destroy();
+ stdout.destroy();
+ stderr.destroy();
+ }
+ });
+});
diff --git a/src/tui/components/index.ts b/src/tui/components/index.ts
index f26db2c2..96e2cd05 100644
--- a/src/tui/components/index.ts
+++ b/src/tui/components/index.ts
@@ -16,3 +16,4 @@ export * from './StatusLinePreview';
export * from './TerminalOptionsMenu';
export * from './TerminalWidthMenu';
export * from './UpdateCheckerMenu';
+export * from './UsageTrackerMenu';
diff --git a/src/types/Settings.ts b/src/types/Settings.ts
index a596dbcf..ee022f84 100644
--- a/src/types/Settings.ts
+++ b/src/types/Settings.ts
@@ -45,6 +45,18 @@ export const SettingsSchema_v1 = z.object({
globalBold: z.boolean().optional()
});
+// Usage Tracker (see src/utils/usage-log.ts). Additive with full defaults,
+// so no CURRENT_VERSION bump is needed
+export const UsageTrackerConfigSchema = z.object({
+ enabled: z.boolean().default(false),
+ logApiUsage: z.boolean().default(true),
+ logPath: z.string().optional(),
+ heartbeatMinutes: z.number().min(1).max(120).default(10),
+ rotateMaxMb: z.number().min(1).max(100).default(5)
+});
+
+export type UsageTrackerConfig = z.infer;
+
// Main settings schema with defaults
export const SettingsSchema = z.object({
version: z.number().default(CURRENT_VERSION),
@@ -85,6 +97,12 @@ export const SettingsSchema = z.object({
autoAlign: false,
continueThemeAcrossLines: false
}),
+ usageTracker: UsageTrackerConfigSchema.default({
+ enabled: false,
+ logApiUsage: true,
+ heartbeatMinutes: 10,
+ rotateMaxMb: 5
+ }),
updatemessage: z.object({
message: z.string().nullable().optional(),
remaining: z.number().nullable().optional()
diff --git a/src/utils/__tests__/config.test.ts b/src/utils/__tests__/config.test.ts
index 167fb4f0..d2649f15 100644
--- a/src/utils/__tests__/config.test.ts
+++ b/src/utils/__tests__/config.test.ts
@@ -481,6 +481,38 @@ describe('config utilities', () => {
expect(getConfigLoadError()).toBeNull();
});
+ it('applies usageTracker defaults when loading a settings file that predates it', async () => {
+ const { settingsPath, configDir } = getSettingsPaths();
+ fs.mkdirSync(configDir, { recursive: true });
+ const original = JSON.stringify({ version: CURRENT_VERSION, lines: [[], [], []] });
+ fs.writeFileSync(settingsPath, original, 'utf-8');
+
+ const settings = await loadSettings();
+
+ expect(settings.usageTracker).toEqual({
+ enabled: false,
+ logApiUsage: true,
+ heartbeatMinutes: 10,
+ rotateMaxMb: 5
+ });
+
+ // Loading alone does not rewrite the legacy file; the key round-trips
+ // through the next save.
+ expect(fs.readFileSync(settingsPath, 'utf-8')).toBe(original);
+
+ await saveSettings({
+ ...settings,
+ usageTracker: {
+ ...settings.usageTracker,
+ enabled: true
+ }
+ });
+
+ const reloaded = await loadSettings();
+ expect(reloaded.usageTracker.enabled).toBe(true);
+ expect(reloaded.usageTracker.logApiUsage).toBe(true);
+ });
+
it('silently rewrites legacy git-pr widget type to git-review on load', async () => {
const { settingsPath, configDir } = getSettingsPaths();
fs.mkdirSync(configDir, { recursive: true });
diff --git a/src/utils/__tests__/usage-log.test.ts b/src/utils/__tests__/usage-log.test.ts
new file mode 100644
index 00000000..d54f346b
--- /dev/null
+++ b/src/utils/__tests__/usage-log.test.ts
@@ -0,0 +1,462 @@
+import * as fs from 'fs';
+import * as os from 'os';
+import path from 'path';
+import {
+ afterAll,
+ afterEach,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi
+} from 'vitest';
+
+import type { UsageTrackerConfig } from '../../types/Settings';
+import {
+ __testing,
+ initUsageLog,
+ logApiUsagePayload,
+ logStdinRateLimits
+} from '../usage-log';
+
+const TEST_ROOT = '/tmp/ccstatusline-usage-log-test';
+const LOG_PATH = path.join(TEST_ROOT, 'usage-log.jsonl');
+const ROTATED_PATH = path.join(TEST_ROOT, 'usage-log.1.jsonl');
+const STATE_PATH = path.join(TEST_ROOT, 'usage-log.state.json');
+const ORIGINAL_XDG_DATA_HOME = process.env.XDG_DATA_HOME;
+
+const SAMPLE_RATE_LIMITS = {
+ five_hour: { used_percentage: 2, resets_at: 1785953400 },
+ seven_day: { used_percentage: 30, resets_at: 1786276799 },
+ mystery_bucket: { used_percentage: 7 }
+};
+
+function makeConfig(overrides: Partial = {}): UsageTrackerConfig {
+ return {
+ enabled: true,
+ logApiUsage: true,
+ logPath: LOG_PATH,
+ heartbeatMinutes: 10,
+ rotateMaxMb: 5,
+ ...overrides
+ };
+}
+
+function readLogRecords(logPath = LOG_PATH): Record[] {
+ if (!fs.existsSync(logPath)) {
+ return [];
+ }
+
+ return fs.readFileSync(logPath, 'utf8')
+ .split('\n')
+ .filter(line => line.length > 0)
+ .map(line => JSON.parse(line) as Record);
+}
+
+function readStateFile(): Record {
+ return JSON.parse(fs.readFileSync(STATE_PATH, 'utf8')) as Record;
+}
+
+describe('usage-log', () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ __testing.reset();
+ fs.rmSync(TEST_ROOT, { recursive: true, force: true });
+ fs.mkdirSync(TEST_ROOT, { recursive: true });
+ delete process.env.XDG_DATA_HOME;
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ __testing.reset();
+ });
+
+ afterAll(() => {
+ fs.rmSync(TEST_ROOT, { recursive: true, force: true });
+ if (ORIGINAL_XDG_DATA_HOME === undefined) {
+ delete process.env.XDG_DATA_HOME;
+ } else {
+ process.env.XDG_DATA_HOME = ORIGINAL_XDG_DATA_HOME;
+ }
+ });
+
+ describe('canonicalJson', () => {
+ it('sorts object keys recursively', () => {
+ const json = __testing.canonicalJson({
+ b: 1,
+ a: {
+ d: 2,
+ c: [3, { f: 4, e: 5 }]
+ }
+ });
+
+ expect(json).toBe('{"a":{"c":[3,{"e":5,"f":4}],"d":2},"b":1}');
+ });
+
+ it('keeps array order', () => {
+ expect(__testing.canonicalJson([2, 1, { b: 0, a: 0 }])).toBe('[2,1,{"a":0,"b":0}]');
+ });
+ });
+
+ describe('computeSignature', () => {
+ it('is stable across object key ordering', () => {
+ const a = __testing.computeSignature('acct', { x: 1, y: { b: 2, a: 3 } });
+ const b = __testing.computeSignature('acct', { y: { a: 3, b: 2 }, x: 1 });
+
+ expect(a).toBe(b);
+ expect(a).toMatch(/^[0-9a-f]{16}$/);
+ });
+
+ it('changes when a value changes', () => {
+ const a = __testing.computeSignature('acct', { used_percentage: 2 });
+ const b = __testing.computeSignature('acct', { used_percentage: 3 });
+
+ expect(a).not.toBe(b);
+ });
+
+ it('changes when the account changes', () => {
+ const raw = { used_percentage: 2 };
+
+ expect(__testing.computeSignature('acct-a', raw)).not.toBe(__testing.computeSignature('acct-b', raw));
+ expect(__testing.computeSignature(undefined, raw)).not.toBe(__testing.computeSignature('acct-a', raw));
+ });
+ });
+
+ describe('buildRecord', () => {
+ it('builds a stdin record with truncated session id observation', () => {
+ const record = __testing.buildRecord(
+ 'stdin',
+ 1785953662881,
+ 'a3f9c1d2e5b70614',
+ SAMPLE_RATE_LIMITS,
+ { sessionId: '237bc35a-1234-5678', modelId: 'claude-opus-5' }
+ );
+
+ expect(record).toEqual({
+ v: 1,
+ t: new Date(1785953662881).toISOString(),
+ src: 'stdin',
+ acct: 'a3f9c1d2e5b70614',
+ raw: SAMPLE_RATE_LIMITS,
+ obs: { sid: '237bc35a', model: 'claude-opus-5' }
+ });
+ });
+
+ it('omits acct when unknown and obs when empty', () => {
+ const record = __testing.buildRecord('stdin', 0, undefined, SAMPLE_RATE_LIMITS, {});
+
+ expect(record).not.toHaveProperty('acct');
+ expect(record).not.toHaveProperty('obs');
+ });
+
+ it('builds hb records without raw or obs', () => {
+ const record = __testing.buildRecord('hb', 0, 'a3f9c1d2e5b70614', SAMPLE_RATE_LIMITS, { sessionId: 'x' });
+
+ expect(record).toEqual({
+ v: 1,
+ t: '1970-01-01T00:00:00.000Z',
+ src: 'hb',
+ acct: 'a3f9c1d2e5b70614'
+ });
+ });
+ });
+
+ describe('path resolution', () => {
+ it('uses XDG_DATA_HOME when set', () => {
+ expect(__testing.resolveDataDir({ XDG_DATA_HOME: '/xdg/data' })).toBe(path.join('/xdg/data', 'ccstatusline'));
+ });
+
+ it('falls back to ~/.local/share when XDG_DATA_HOME is unset or blank', () => {
+ const fallback = path.join(os.homedir(), '.local', 'share', 'ccstatusline');
+
+ expect(__testing.resolveDataDir({})).toBe(fallback);
+ expect(__testing.resolveDataDir({ XDG_DATA_HOME: ' ' })).toBe(fallback);
+ });
+
+ it('keeps the rotated and state files next to an overridden log path', () => {
+ const paths = __testing.resolveLogPaths(makeConfig({ logPath: '/custom/dir/my-log.jsonl' }));
+
+ expect(paths).toEqual({
+ logPath: '/custom/dir/my-log.jsonl',
+ rotatedPath: '/custom/dir/my-log.1.jsonl',
+ statePath: '/custom/dir/my-log.state.json'
+ });
+ });
+
+ it('derives default paths from the data dir', () => {
+ process.env.XDG_DATA_HOME = TEST_ROOT;
+ const paths = __testing.resolveLogPaths(makeConfig({ logPath: undefined }));
+
+ expect(paths.logPath).toBe(path.join(TEST_ROOT, 'ccstatusline', 'usage-log.jsonl'));
+ expect(paths.rotatedPath).toBe(path.join(TEST_ROOT, 'ccstatusline', 'usage-log.1.jsonl'));
+ expect(paths.statePath).toBe(path.join(TEST_ROOT, 'ccstatusline', 'usage-log.state.json'));
+ });
+ });
+
+ describe('logStdinRateLimits', () => {
+ it('appends a lossless stdin record including unknown buckets', () => {
+ initUsageLog(makeConfig(), { sessionId: '237bc35a-1234', modelId: 'claude-opus-5' });
+
+ logStdinRateLimits({ ...SAMPLE_RATE_LIMITS, seven_day_opus: null });
+
+ const records = readLogRecords();
+ expect(records).toHaveLength(1);
+ expect(records[0]?.src).toBe('stdin');
+ expect(records[0]?.raw).toEqual({ ...SAMPLE_RATE_LIMITS, seven_day_opus: null });
+ expect(records[0]?.obs).toEqual({ sid: '237bc35a', model: 'claude-opus-5' });
+ expect(readStateFile()).toMatchObject({ v: 1, stdin: { sig: expect.any(String) as unknown } });
+ });
+
+ it('appends nothing when the signature is unchanged', () => {
+ initUsageLog(makeConfig());
+
+ logStdinRateLimits(SAMPLE_RATE_LIMITS);
+ logStdinRateLimits({ ...SAMPLE_RATE_LIMITS });
+
+ expect(readLogRecords()).toHaveLength(1);
+ });
+
+ it('appends a new record when a percentage changes', () => {
+ initUsageLog(makeConfig());
+
+ logStdinRateLimits(SAMPLE_RATE_LIMITS);
+ logStdinRateLimits({
+ ...SAMPLE_RATE_LIMITS,
+ five_hour: { used_percentage: 3, resets_at: 1785953400 }
+ });
+
+ const records = readLogRecords();
+ expect(records).toHaveLength(2);
+ expect((records[1]?.raw as { five_hour: { used_percentage: number } }).five_hour.used_percentage).toBe(3);
+ });
+
+ it('does nothing when disabled or uninitialized', () => {
+ logStdinRateLimits(SAMPLE_RATE_LIMITS);
+
+ initUsageLog(makeConfig({ enabled: false }));
+ logStdinRateLimits(SAMPLE_RATE_LIMITS);
+
+ expect(fs.existsSync(LOG_PATH)).toBe(false);
+ expect(fs.existsSync(STATE_PATH)).toBe(false);
+ });
+
+ it('appends a heartbeat when rate_limits is absent and nothing was ever logged', () => {
+ initUsageLog(makeConfig(), { sessionId: 'should-not-appear' });
+
+ logStdinRateLimits(undefined);
+
+ const records = readLogRecords();
+ expect(records).toHaveLength(1);
+ expect(records[0]?.src).toBe('hb');
+ expect(records[0]).not.toHaveProperty('raw');
+ expect(records[0]).not.toHaveProperty('obs');
+ });
+
+ it('treats null rate_limits as absent', () => {
+ initUsageLog(makeConfig());
+
+ logStdinRateLimits(null);
+
+ const records = readLogRecords();
+ expect(records).toHaveLength(1);
+ expect(records[0]?.src).toBe('hb');
+ });
+
+ it('suppresses heartbeats until heartbeatMinutes have passed since the last append', () => {
+ const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1_785_953_600_000);
+ initUsageLog(makeConfig());
+
+ logStdinRateLimits(SAMPLE_RATE_LIMITS);
+
+ // Shortly after the stdin record: unchanged signature, no heartbeat.
+ nowSpy.mockReturnValue(1_785_953_600_000 + 60_000);
+ logStdinRateLimits(SAMPLE_RATE_LIMITS);
+ expect(readLogRecords()).toHaveLength(1);
+
+ // Past the heartbeat interval: an hb record is appended.
+ nowSpy.mockReturnValue(1_785_953_600_000 + 10 * 60_000);
+ logStdinRateLimits(SAMPLE_RATE_LIMITS);
+ const records = readLogRecords();
+ expect(records).toHaveLength(2);
+ expect(records[1]?.src).toBe('hb');
+
+ // The heartbeat refreshed lastHbAt, so the next render is quiet again.
+ nowSpy.mockReturnValue(1_785_953_600_000 + 10 * 60_000 + 60_000);
+ logStdinRateLimits(SAMPLE_RATE_LIMITS);
+ expect(readLogRecords()).toHaveLength(2);
+ });
+
+ it('treats a corrupt state file as no previous signature and rewrites it', () => {
+ initUsageLog(makeConfig());
+ logStdinRateLimits(SAMPLE_RATE_LIMITS);
+ fs.writeFileSync(STATE_PATH, '{ not json');
+
+ logStdinRateLimits(SAMPLE_RATE_LIMITS);
+
+ expect(readLogRecords()).toHaveLength(2);
+ expect(readStateFile()).toMatchObject({ v: 1 });
+ });
+
+ it('swallows all errors when the log directory is unwritable', () => {
+ if (process.getuid?.() === 0) {
+ // Root ignores directory permissions; nothing to assert.
+ return;
+ }
+
+ const lockedDir = path.join(TEST_ROOT, 'locked');
+ fs.mkdirSync(lockedDir, { recursive: true });
+ fs.chmodSync(lockedDir, 0o555);
+ initUsageLog(makeConfig({ logPath: path.join(lockedDir, 'usage-log.jsonl') }));
+
+ try {
+ expect(() => { logStdinRateLimits(SAMPLE_RATE_LIMITS); }).not.toThrow();
+ expect(fs.readdirSync(lockedDir)).toEqual([]);
+ } finally {
+ fs.chmodSync(lockedDir, 0o755);
+ }
+ });
+
+ it('records the cached account hash and registers an account switch as a change', () => {
+ const cacheFile = path.join(TEST_ROOT, 'usage.json');
+ fs.writeFileSync(cacheFile, JSON.stringify({ sessionUsage: 2, tokenHash: 'a3f9c1d2e5b70614' }));
+ __testing.setUsageCacheFileForTesting(cacheFile);
+ initUsageLog(makeConfig());
+
+ logStdinRateLimits(SAMPLE_RATE_LIMITS);
+ fs.writeFileSync(cacheFile, JSON.stringify({ sessionUsage: 2, tokenHash: 'ffffffffffffffff' }));
+ logStdinRateLimits(SAMPLE_RATE_LIMITS);
+
+ const records = readLogRecords();
+ expect(records).toHaveLength(2);
+ expect(records[0]?.acct).toBe('a3f9c1d2e5b70614');
+ expect(records[1]?.acct).toBe('ffffffffffffffff');
+ });
+
+ it('omits acct when the usage cache has no token hash', () => {
+ __testing.setUsageCacheFileForTesting(path.join(TEST_ROOT, 'missing-usage.json'));
+ initUsageLog(makeConfig());
+
+ logStdinRateLimits(SAMPLE_RATE_LIMITS);
+
+ expect(readLogRecords()[0]).not.toHaveProperty('acct');
+ });
+
+ it('rotates an oversized log before appending, replacing any previous rotation', () => {
+ initUsageLog(makeConfig({ rotateMaxMb: 1 }));
+ const oversized = `${'x'.repeat(1024 * 1024 + 16)}\n`;
+ fs.writeFileSync(LOG_PATH, oversized);
+ fs.writeFileSync(ROTATED_PATH, 'previous rotation\n');
+
+ logStdinRateLimits(SAMPLE_RATE_LIMITS);
+
+ expect(fs.readFileSync(ROTATED_PATH, 'utf8')).toBe(oversized);
+ const records = readLogRecords();
+ expect(records).toHaveLength(1);
+ expect(records[0]?.src).toBe('stdin');
+ });
+
+ it('leaves no temp files behind after writing state', () => {
+ initUsageLog(makeConfig());
+
+ logStdinRateLimits(SAMPLE_RATE_LIMITS);
+
+ expect(fs.readdirSync(TEST_ROOT).filter(name => name.includes('.tmp'))).toEqual([]);
+ });
+ });
+
+ describe('logApiUsagePayload', () => {
+ const API_BODY = JSON.stringify({
+ five_hour: { utilization: 12, resets_at: '2026-08-06T12:00:00Z' },
+ tangelo: { utilization: 1 },
+ limits: [{ kind: 'weekly_scoped', percent: 3 }]
+ });
+
+ it('appends the verbatim parsed body including unknown buckets', () => {
+ initUsageLog(makeConfig(), { sessionId: 'sess', modelId: 'model' });
+
+ logApiUsagePayload(API_BODY, 'a3f9c1d2e5b70614');
+
+ const records = readLogRecords();
+ expect(records).toHaveLength(1);
+ expect(records[0]?.src).toBe('api');
+ expect(records[0]?.acct).toBe('a3f9c1d2e5b70614');
+ expect(records[0]?.raw).toEqual(JSON.parse(API_BODY));
+ expect(records[0]).not.toHaveProperty('obs');
+ });
+
+ it('dedups identical payloads per source', () => {
+ initUsageLog(makeConfig());
+
+ logApiUsagePayload(API_BODY, 'a3f9c1d2e5b70614');
+ logApiUsagePayload(API_BODY, 'a3f9c1d2e5b70614');
+
+ expect(readLogRecords()).toHaveLength(1);
+ });
+
+ it('tracks stdin and api signatures independently', () => {
+ initUsageLog(makeConfig());
+ const shared = { five_hour: { used_percentage: 2 } };
+
+ logStdinRateLimits(shared);
+ logApiUsagePayload(JSON.stringify(shared), null);
+
+ const records = readLogRecords();
+ expect(records).toHaveLength(2);
+ expect(records.map(record => record.src)).toEqual(['stdin', 'api']);
+ });
+
+ it('omits acct for a null token hash', () => {
+ initUsageLog(makeConfig());
+
+ logApiUsagePayload(API_BODY, null);
+
+ expect(readLogRecords()[0]).not.toHaveProperty('acct');
+ });
+
+ it('does nothing when logApiUsage is disabled or the logger is uninitialized', () => {
+ logApiUsagePayload(API_BODY, null);
+
+ initUsageLog(makeConfig({ logApiUsage: false }));
+ logApiUsagePayload(API_BODY, null);
+
+ expect(fs.existsSync(LOG_PATH)).toBe(false);
+ });
+
+ it('swallows an unparseable body', () => {
+ initUsageLog(makeConfig());
+
+ expect(() => { logApiUsagePayload('not json', null); }).not.toThrow();
+ expect(fs.existsSync(LOG_PATH)).toBe(false);
+ });
+
+ it('suppresses the next heartbeat after an api record', () => {
+ const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1_785_953_600_000);
+ initUsageLog(makeConfig());
+
+ logApiUsagePayload(API_BODY, null);
+
+ nowSpy.mockReturnValue(1_785_953_600_000 + 60_000);
+ logStdinRateLimits(undefined);
+
+ expect(readLogRecords()).toHaveLength(1);
+ });
+ });
+
+ describe('decision helpers', () => {
+ it('shouldHeartbeat treats a missing lastHbAt as stale', () => {
+ expect(__testing.shouldHeartbeat(undefined, 1000, 10)).toBe(true);
+ });
+
+ it('shouldHeartbeat compares against heartbeatMinutes', () => {
+ const now = 20 * 60_000;
+
+ expect(__testing.shouldHeartbeat(now - 10 * 60_000, now, 10)).toBe(true);
+ expect(__testing.shouldHeartbeat(now - 10 * 60_000 + 1, now, 10)).toBe(false);
+ });
+
+ it('shouldRotate compares against rotateMaxMb', () => {
+ expect(__testing.shouldRotate(5 * 1024 * 1024, 5)).toBe(false);
+ expect(__testing.shouldRotate(5 * 1024 * 1024 + 1, 5)).toBe(true);
+ });
+ });
+});
diff --git a/src/utils/__tests__/usage-prefetch.test.ts b/src/utils/__tests__/usage-prefetch.test.ts
index 50c81a56..ad01b17e 100644
--- a/src/utils/__tests__/usage-prefetch.test.ts
+++ b/src/utils/__tests__/usage-prefetch.test.ts
@@ -550,6 +550,60 @@ describe('usage prefetch', () => {
expect(mockFetchUsageData.mock.calls.length).toBe(1);
});
+ it('force-fetches usage data for the tracker api log when no usage widgets exist', async () => {
+ mockFetchUsageData.mockResolvedValue({ sessionUsage: 12 });
+
+ const lines = makeLines(
+ [{ id: '1', type: 'model' }],
+ [{ id: '2', type: 'git-branch' }]
+ );
+
+ const usageData = await prefetchUsageDataIfNeeded(lines, {}, { forceUsageFetch: true });
+
+ expect(mockFetchUsageData.mock.calls).toEqual([[{ requiredFields: [] }]]);
+ expect(usageData).toEqual({ sessionUsage: 12 });
+ });
+
+ it('preserves the early return when no forced fetch is requested', async () => {
+ const lines = makeLines(
+ [{ id: '1', type: 'model' }],
+ [{ id: '2', type: 'git-branch' }]
+ );
+
+ expect(await prefetchUsageDataIfNeeded(lines, {}, { forceUsageFetch: false })).toBeNull();
+ expect(await prefetchUsageDataIfNeeded(lines, {}, {})).toBeNull();
+ expect(mockFetchUsageData.mock.calls.length).toBe(0);
+ });
+
+ it('force-fetches for the api log without changing render data when stdin satisfies every widget', async () => {
+ mockFetchUsageData.mockResolvedValue({
+ sessionUsage: 99,
+ weeklySonnetUsage: 1,
+ error: 'rate-limited'
+ });
+
+ const lines = makeLines(
+ [{ id: '1', type: 'session-usage' }]
+ );
+
+ const usageData = await prefetchUsageDataIfNeeded(lines, {
+ rate_limits: {
+ five_hour: { used_percentage: 42, resets_at: 1774020000 },
+ seven_day: { used_percentage: 15, resets_at: 1774540000 }
+ }
+ }, { forceUsageFetch: true });
+
+ expect(mockFetchUsageData.mock.calls).toEqual([[{ requiredFields: [] }]]);
+ // The fetch fires purely for the log hook; its result (including the
+ // error) is discarded so rendering matches the untracked behavior.
+ expect(usageData).toEqual({
+ sessionUsage: 42,
+ sessionResetAt: epochToIso(1774020000),
+ weeklyUsage: 15,
+ weeklyResetAt: epochToIso(1774540000)
+ });
+ });
+
it('falls back to API fetch when sessionResetAt is missing from rate_limits', async () => {
mockFetchUsageData.mockResolvedValue({
sessionUsage: 42,
diff --git a/src/utils/usage-fetch.ts b/src/utils/usage-fetch.ts
index e2f48285..d4b8fa38 100644
--- a/src/utils/usage-fetch.ts
+++ b/src/utils/usage-fetch.ts
@@ -8,6 +8,7 @@ import * as path from 'path';
import { z } from 'zod';
import { getClaudeConfigDir } from './claude-settings';
+import { logApiUsagePayload } from './usage-log';
import type {
UsageData,
UsageDataField,
@@ -790,6 +791,11 @@ export async function fetchUsageData(options: FetchUsageDataOptions = {}): Promi
// Ignore cache write errors
}
+ // Usage Tracker api path: only the process that performed the live
+ // fetch reaches this point, so multi-session dedup is inherited from
+ // the cache design. No-op unless the piped render initialized the log.
+ logApiUsagePayload(response.body, currentTokenHash);
+
// Clear the in-flight lock written above only once this response satisfies
// the caller's requested fields. Incomplete 200 responses are cached but
// still need the short throttle so later renders do not refetch every time.
diff --git a/src/utils/usage-log.ts b/src/utils/usage-log.ts
new file mode 100644
index 00000000..c89282d0
--- /dev/null
+++ b/src/utils/usage-log.ts
@@ -0,0 +1,331 @@
+import { createHash } from 'crypto';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import { z } from 'zod';
+
+import type { UsageTrackerConfig } from '../types/Settings';
+
+// Usage Tracker: appends every distinct rate-limit observation to a JSONL log
+// for later analysis. `raw` is captured verbatim (pre-zod stdin payload /
+// API response body), never mapped through UsageData shapes, so unknown
+// buckets survive schema drift. No locking: concurrent sessions may rarely
+// append duplicate records, which the analyzer dedups; the state file gives
+// value-based dedup only. Every public function swallows all errors so
+// logging can never break rendering.
+//
+// Must not import from usage-fetch.ts (it imports the api hook from here).
+// The stdin-path account hash is instead read from the tokenHash that
+// usage-fetch.ts persists in usage.json; after an account switch, stdin
+// records may carry the previous account's hash until the next live API
+// fetch rewrites the cache (bounded by the 180s cache TTL + 30s lock).
+
+const LOG_ENVELOPE_VERSION = 1;
+
+// usage-fetch.ts's CACHE_FILE, duplicated to avoid the import cycle above
+const DEFAULT_USAGE_CACHE_FILE = path.join(os.homedir(), '.cache', 'ccstatusline', 'usage.json');
+
+const UsageCacheTokenHashSchema = z.object({ tokenHash: z.string().optional() });
+
+const UsageLogStateSchema = z.object({
+ v: z.number().optional(),
+ stdin: z.object({ sig: z.string() }).optional(),
+ api: z.object({ sig: z.string() }).optional(),
+ lastHbAt: z.number().optional()
+});
+
+type UsageLogState = z.infer;
+
+export type UsageLogSource = 'stdin' | 'api' | 'hb';
+
+export interface UsageLogObservation {
+ modelId?: string;
+ sessionId?: string;
+}
+
+interface UsageLogPaths {
+ logPath: string;
+ rotatedPath: string;
+ statePath: string;
+}
+
+interface UsageLogRecord {
+ v: number;
+ t: string;
+ src: UsageLogSource;
+ acct?: string;
+ raw?: unknown;
+ obs?: {
+ sid?: string;
+ model?: string;
+ };
+}
+
+// Per-render module state; only the piped render path calls initUsageLog,
+// which keeps the TUI, --hook mode, and other fetchUsageData callers silent
+let activeConfig: UsageTrackerConfig | null = null;
+let activeObs: UsageLogObservation = {};
+let usageCacheFile = DEFAULT_USAGE_CACHE_FILE;
+
+function canonicalize(value: unknown): unknown {
+ if (Array.isArray(value)) {
+ return value.map(canonicalize);
+ }
+
+ if (value !== null && typeof value === 'object') {
+ const source = value as Record;
+ const sorted: Record = {};
+ for (const key of Object.keys(source).sort()) {
+ sorted[key] = canonicalize(source[key]);
+ }
+
+ return sorted;
+ }
+
+ return value;
+}
+
+// Recursively sorted object keys so the signature is stable regardless of
+// payload key ordering (arrays keep their order)
+function canonicalJson(value: unknown): string {
+ return JSON.stringify(canonicalize(value));
+}
+
+// Including acct makes an account switch register as a change
+function computeSignature(acct: string | undefined, raw: unknown): string {
+ return createHash('sha256')
+ .update(canonicalJson({ acct, raw }))
+ .digest('hex')
+ .slice(0, 16);
+}
+
+function resolveDataDir(env: NodeJS.ProcessEnv = process.env): string {
+ const xdgDataHome = env.XDG_DATA_HOME?.trim();
+ // XDG data, not ~/.cache: the log is unrecoverable if deleted
+ return xdgDataHome
+ ? path.join(xdgDataHome, 'ccstatusline')
+ : path.join(os.homedir(), '.local', 'share', 'ccstatusline');
+}
+
+// Rotated file and dedup state live next to the resolved log, including when
+// config.logPath overrides the default location
+function resolveLogPaths(config: UsageTrackerConfig): UsageLogPaths {
+ const logPath = config.logPath ?? path.join(resolveDataDir(), 'usage-log.jsonl');
+ const base = logPath.endsWith('.jsonl') ? logPath.slice(0, -'.jsonl'.length) : logPath;
+ return {
+ logPath,
+ rotatedPath: `${base}.1.jsonl`,
+ statePath: `${base}.state.json`
+ };
+}
+
+// Display helper for the TUI; the log path is resolved the same way the
+// logger resolves it, including the config.logPath override
+export function getResolvedLogPath(config: UsageTrackerConfig): string {
+ try {
+ return resolveLogPaths(config).logPath;
+ } catch {
+ return config.logPath ?? '';
+ }
+}
+
+function buildRecord(
+ src: UsageLogSource,
+ nowMs: number,
+ acct: string | undefined,
+ raw?: unknown,
+ obs?: UsageLogObservation
+): UsageLogRecord {
+ const record: UsageLogRecord = {
+ v: LOG_ENVELOPE_VERSION,
+ t: new Date(nowMs).toISOString(),
+ src
+ };
+
+ if (acct !== undefined) {
+ record.acct = acct;
+ }
+
+ if (src !== 'hb' && raw !== undefined) {
+ record.raw = raw;
+ }
+
+ if (src === 'stdin' && obs && (obs.sessionId !== undefined || obs.modelId !== undefined)) {
+ record.obs = {
+ ...(obs.sessionId !== undefined ? { sid: obs.sessionId.slice(0, 8) } : {}),
+ ...(obs.modelId !== undefined ? { model: obs.modelId } : {})
+ };
+ }
+
+ return record;
+}
+
+// lastHbAt is refreshed by every appended record (any record proves the
+// logger was alive), so a heartbeat only fires after a quiet stretch
+function shouldHeartbeat(lastHbAt: number | undefined, nowMs: number, heartbeatMinutes: number): boolean {
+ return lastHbAt === undefined || nowMs - lastHbAt >= heartbeatMinutes * 60_000;
+}
+
+function shouldRotate(logSizeBytes: number, rotateMaxMb: number): boolean {
+ return logSizeBytes > rotateMaxMb * 1024 * 1024;
+}
+
+// Corrupt or missing state degrades to "no previous signature"
+function readState(statePath: string): UsageLogState {
+ try {
+ const parsed = UsageLogStateSchema.safeParse(JSON.parse(fs.readFileSync(statePath, 'utf8')));
+ return parsed.success ? parsed.data : {};
+ } catch {
+ return {};
+ }
+}
+
+// Atomic replace (temp file + rename) so a concurrent reader never sees a
+// torn state file
+function writeState(statePath: string, state: UsageLogState): void {
+ const tempPath = `${statePath}.tmp.${process.pid}`;
+ try {
+ fs.writeFileSync(tempPath, JSON.stringify({ ...state, v: 1 }));
+ fs.renameSync(tempPath, statePath);
+ } catch {
+ try {
+ fs.rmSync(tempPath, { force: true });
+ } catch {
+ // Ignore cleanup errors
+ }
+ }
+}
+
+function readStdinAccountHash(): string | undefined {
+ try {
+ const parsed = UsageCacheTokenHashSchema.safeParse(JSON.parse(fs.readFileSync(usageCacheFile, 'utf8')));
+ return parsed.success ? parsed.data.tokenHash : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+function rotateIfNeeded(paths: UsageLogPaths, rotateMaxMb: number): void {
+ try {
+ if (shouldRotate(fs.statSync(paths.logPath).size, rotateMaxMb)) {
+ fs.renameSync(paths.logPath, paths.rotatedPath);
+ }
+ } catch {
+ // Missing log or a lost rename race; skip rotation, never block the append
+ }
+}
+
+function appendRecord(paths: UsageLogPaths, record: UsageLogRecord, rotateMaxMb: number): void {
+ fs.mkdirSync(path.dirname(paths.logPath), { recursive: true });
+ rotateIfNeeded(paths, rotateMaxMb);
+ // Single O_APPEND write; lines under PIPE_BUF append atomically on POSIX,
+ // oversized api lines may very rarely interleave under concurrency
+ fs.appendFileSync(paths.logPath, `${JSON.stringify(record)}\n`);
+}
+
+export function initUsageLog(config: UsageTrackerConfig, obs: UsageLogObservation = {}): void {
+ try {
+ activeConfig = config;
+ activeObs = obs;
+ } catch {
+ // Never let logging break rendering
+ }
+}
+
+// stdin path: dedup + heartbeat + rotation. rawRateLimits must be the
+// pre-zod value from the raw JSON.parse of stdin (schema validation strips
+// unknown buckets).
+export function logStdinRateLimits(rawRateLimits: unknown): void {
+ try {
+ if (!activeConfig?.enabled) {
+ return;
+ }
+
+ const paths = resolveLogPaths(activeConfig);
+ const state = readState(paths.statePath);
+ const nowMs = Date.now();
+ const acct = readStdinAccountHash();
+ let appended = false;
+
+ // A null rate_limits is "no data", not an observation worth a record
+ if (rawRateLimits !== undefined && rawRateLimits !== null) {
+ const sig = computeSignature(acct, rawRateLimits);
+ if (state.stdin?.sig !== sig) {
+ appendRecord(paths, buildRecord('stdin', nowMs, acct, rawRateLimits, activeObs), activeConfig.rotateMaxMb);
+ state.stdin = { sig };
+ state.lastHbAt = nowMs;
+ appended = true;
+ }
+ }
+
+ // Heartbeats make gaps unambiguous ("no usage" vs "not running"),
+ // and run even when rate_limits is absent from the payload
+ if (!appended && shouldHeartbeat(state.lastHbAt, nowMs, activeConfig.heartbeatMinutes)) {
+ appendRecord(paths, buildRecord('hb', nowMs, acct), activeConfig.rotateMaxMb);
+ state.lastHbAt = nowMs;
+ appended = true;
+ }
+
+ if (appended) {
+ writeState(paths.statePath, state);
+ }
+ } catch {
+ // Never let logging break rendering
+ }
+}
+
+// api path: called by fetchUsageData at its cache-write point, so only the
+// one process that performed the live fetch logs a record. No-op unless
+// initUsageLog ran, which scopes it to the piped render path.
+export function logApiUsagePayload(rawBody: string, tokenHash: string | null): void {
+ try {
+ if (!activeConfig?.enabled || !activeConfig.logApiUsage) {
+ return;
+ }
+
+ const raw: unknown = JSON.parse(rawBody);
+ const acct = tokenHash ?? undefined;
+ const paths = resolveLogPaths(activeConfig);
+ const state = readState(paths.statePath);
+ const sig = computeSignature(acct, raw);
+
+ if (state.api?.sig === sig) {
+ return;
+ }
+
+ const nowMs = Date.now();
+ appendRecord(paths, buildRecord('api', nowMs, acct, raw), activeConfig.rotateMaxMb);
+ writeState(paths.statePath, {
+ ...state,
+ api: { sig },
+ lastHbAt: nowMs
+ });
+ } catch {
+ // Never let logging break rendering
+ }
+}
+
+function reset(): void {
+ activeConfig = null;
+ activeObs = {};
+ usageCacheFile = DEFAULT_USAGE_CACHE_FILE;
+}
+
+function setUsageCacheFileForTesting(filePath: string): void {
+ usageCacheFile = filePath;
+}
+
+// Exposed for tests only
+export const __testing = {
+ buildRecord,
+ canonicalJson,
+ computeSignature,
+ readState,
+ readStdinAccountHash,
+ reset,
+ resolveDataDir,
+ resolveLogPaths,
+ setUsageCacheFileForTesting,
+ shouldHeartbeat,
+ shouldRotate
+};
diff --git a/src/utils/usage-prefetch.ts b/src/utils/usage-prefetch.ts
index 8fddba02..43fdd248 100644
--- a/src/utils/usage-prefetch.ts
+++ b/src/utils/usage-prefetch.ts
@@ -212,17 +212,43 @@ export function extractUsageDataFromRateLimits(rateLimits: StatusJSON['rate_limi
return hasAnyUsageDataField(usageData) ? usageData : null;
}
-export async function prefetchUsageDataIfNeeded(lines: WidgetItem[][], data?: StatusJSON): Promise {
+export interface PrefetchUsageDataOptions {
+ // Usage Tracker api logging (usage-log.ts): fetchUsageData must run even
+ // when no widget needs its result, so the log hook inside it can fire.
+ // The 180s cache TTL naturally rate-limits the extra fetches.
+ forceUsageFetch?: boolean;
+}
+
+export async function prefetchUsageDataIfNeeded(
+ lines: WidgetItem[][],
+ data?: StatusJSON,
+ options?: PrefetchUsageDataOptions
+): Promise {
+ const rateLimitsData = extractUsageDataFromRateLimits(data?.rate_limits);
+
if (!hasUsageDependentWidgets(lines)) {
- return null;
+ if (!options?.forceUsageFetch) {
+ return null;
+ }
+
+ // Nothing renders the returned data on this path; the fetch exists
+ // purely so the api-path log hook runs.
+ const apiData = await fetchUsageData({ requiredFields: [] });
+ return mergeUsageData(rateLimitsData, apiData);
}
- const rateLimitsData = extractUsageDataFromRateLimits(data?.rate_limits);
const requirements = getUsageFieldRequirements(lines);
const missingRequirements = getMissingFetchRequirements(rateLimitsData, requirements);
const missingFields = missingRequirements.fields;
if (missingFields.length === 0) {
+ if (options?.forceUsageFetch) {
+ // Same hook-firing fetch as above. stdin satisfied every widget,
+ // so the result is deliberately discarded - rendering must stay
+ // identical to the untracked behavior.
+ await fetchUsageData({ requiredFields: [] });
+ }
+
return rateLimitsData;
}
diff --git a/src/widgets/__tests__/CurrentWorkingDir.test.ts b/src/widgets/__tests__/CurrentWorkingDir.test.ts
index 08224a78..3350a961 100644
--- a/src/widgets/__tests__/CurrentWorkingDir.test.ts
+++ b/src/widgets/__tests__/CurrentWorkingDir.test.ts
@@ -44,6 +44,12 @@ describe('CurrentWorkingDirWidget', () => {
globalBold: false,
gitCacheTtlSeconds: 5,
minimalistMode: false,
+ usageTracker: {
+ enabled: false,
+ logApiUsage: true,
+ heartbeatMinutes: 10,
+ rotateMaxMb: 5
+ },
powerline: {
enabled: false,
separators: [],
diff --git a/src/widgets/__tests__/CustomCommand.test.ts b/src/widgets/__tests__/CustomCommand.test.ts
index 35d0956b..9ea2eb8d 100644
--- a/src/widgets/__tests__/CustomCommand.test.ts
+++ b/src/widgets/__tests__/CustomCommand.test.ts
@@ -30,6 +30,12 @@ describe('CustomCommandWidget', () => {
globalBold: false,
gitCacheTtlSeconds: 5,
minimalistMode: false,
+ usageTracker: {
+ enabled: false,
+ logApiUsage: true,
+ heartbeatMinutes: 10,
+ rotateMaxMb: 5
+ },
powerline: {
enabled: false,
separators: [],